1use crate::domain::value_objects::JsonData;
4use axum::{
5 Json, Router,
6 extract::DefaultBodyLimit,
7 http::{
8 HeaderValue, Method, StatusCode,
9 header::{AUTHORIZATION, CONTENT_TYPE},
10 },
11 middleware,
12 response::{IntoResponse, Response},
13 routing::{get, post},
14};
15use serde::{Deserialize, Serialize};
16use std::{
17 sync::Arc,
18 time::{Duration, Instant},
19};
20use tower::limit::GlobalConcurrencyLimitLayer;
21use tower_http::{
22 cors::{AllowOrigin, CorsLayer},
23 timeout::{ResponseBodyTimeoutLayer, TimeoutLayer},
24 trace::TraceLayer,
25};
26
27use crate::{
28 application::{
29 handlers::{
30 command_handlers::SessionCommandHandler,
31 query_handlers::{SessionQueryHandler, StreamQueryHandler, SystemQueryHandler},
32 },
33 queries::SortOrder,
34 },
35 domain::{
36 SessionState,
37 aggregates::stream_session::SessionHealth,
38 entities::Frame,
39 ports::{
40 DictionaryStore, EventPublisherGat, FrameStoreGat, NoopDictionaryStore,
41 SessionSortField, StreamRepositoryGat, StreamStoreGat,
42 },
43 value_objects::{SessionId, StreamId},
44 },
45 infrastructure::{
46 adapters::InMemoryFrameStore,
47 http::middleware::{RateLimitMiddleware, security_middleware},
48 },
49};
50
51#[cfg(all(feature = "compression", not(target_arch = "wasm32")))]
52use super::handlers::dictionary::get_session_dictionary;
53use super::handlers::{
54 health::{get_system_stats, system_health},
55 sessions::{
56 create_session, get_session, get_session_stats, list_sessions, search_sessions,
57 session_health,
58 },
59 streams::{
60 create_stream, generate_frames, get_stream, get_stream_frames, start_stream,
61 stream_stream_frames,
62 },
63};
64
65#[derive(Debug, Clone)]
84#[non_exhaustive]
85pub struct HttpServerConfig {
86 pub allowed_origins: Vec<String>,
103}
104
105impl HttpServerConfig {
106 pub fn new(allowed_origins: Vec<String>) -> Self {
121 Self { allowed_origins }
122 }
123}
124
125impl Default for HttpServerConfig {
126 fn default() -> Self {
130 Self {
131 allowed_origins: vec!["http://localhost:3000".to_string()],
132 }
133 }
134}
135
136fn build_cors_layer(config: &HttpServerConfig) -> Result<CorsLayer, PjsError> {
144 build_cors_layer_from_origins(&config.allowed_origins)
145}
146
147pub(crate) fn build_cors_layer_from_origins(
171 allowed_origins: &[String],
172) -> Result<CorsLayer, PjsError> {
173 let base = CorsLayer::new()
178 .allow_methods([Method::GET, Method::POST])
179 .allow_headers([CONTENT_TYPE, AUTHORIZATION])
180 .max_age(std::time::Duration::from_secs(3600));
181
182 let has_wildcard = allowed_origins.iter().any(|o| o == "*");
183 let has_explicit = allowed_origins.iter().any(|o| o != "*");
184
185 let layer = match (allowed_origins.is_empty(), has_wildcard, has_explicit) {
186 (true, _, _) => base.allow_origin(AllowOrigin::list(std::iter::empty::<HeaderValue>())),
187 (_, true, true) => {
188 return Err(PjsError::HttpError(
189 "CORS: wildcard '*' cannot be combined with explicit origins".into(),
190 ));
191 }
192 (_, true, false) => base.allow_origin(tower_http::cors::Any),
193 (_, false, _) => {
194 let origins: Vec<HeaderValue> = allowed_origins
195 .iter()
196 .map(|o| {
197 o.parse::<HeaderValue>()
198 .map_err(|e| PjsError::HttpError(format!("invalid CORS origin {o:?}: {e}")))
199 })
200 .collect::<Result<_, _>>()?;
201 base.allow_origin(AllowOrigin::list(origins))
202 }
203 };
204 Ok(layer)
205}
206
207pub struct PjsAppState<R, P, S, F = InMemoryFrameStore>
213where
214 R: StreamRepositoryGat + Send + Sync + 'static,
215 P: EventPublisherGat + Send + Sync + 'static,
216 S: StreamStoreGat + Send + Sync + 'static,
217 F: FrameStoreGat + Send + Sync + 'static,
218{
219 pub(crate) command_handler: Arc<SessionCommandHandler<R, P, F>>,
220 pub(crate) session_query_handler: Arc<SessionQueryHandler<R>>,
221 pub(crate) stream_query_handler: Arc<StreamQueryHandler<R, S, F>>,
222 pub(crate) system_handler: Arc<SystemQueryHandler<R>>,
223 pub(crate) dictionary_store: Arc<dyn DictionaryStore>,
224}
225
226impl<R, P, S, F> Clone for PjsAppState<R, P, S, F>
227where
228 R: StreamRepositoryGat + Send + Sync + 'static,
229 P: EventPublisherGat + Send + Sync + 'static,
230 S: StreamStoreGat + Send + Sync + 'static,
231 F: FrameStoreGat + Send + Sync + 'static,
232{
233 fn clone(&self) -> Self {
234 Self {
235 command_handler: self.command_handler.clone(),
236 session_query_handler: self.session_query_handler.clone(),
237 stream_query_handler: self.stream_query_handler.clone(),
238 system_handler: self.system_handler.clone(),
239 dictionary_store: self.dictionary_store.clone(),
240 }
241 }
242}
243
244impl<R, P, S> PjsAppState<R, P, S, InMemoryFrameStore>
245where
246 R: StreamRepositoryGat + Send + Sync + 'static,
247 P: EventPublisherGat + Send + Sync + 'static,
248 S: StreamStoreGat + Send + Sync + 'static,
249{
250 pub fn new(repository: Arc<R>, event_publisher: Arc<P>, stream_store: Arc<S>) -> Self {
259 Self::with_dictionary_store(
260 repository,
261 event_publisher,
262 stream_store,
263 Arc::new(NoopDictionaryStore),
264 )
265 }
266
267 pub fn with_dictionary_store(
273 repository: Arc<R>,
274 event_publisher: Arc<P>,
275 stream_store: Arc<S>,
276 dictionary_store: Arc<dyn DictionaryStore>,
277 ) -> Self {
278 Self::with_stores(
279 repository,
280 event_publisher,
281 stream_store,
282 dictionary_store,
283 Arc::new(InMemoryFrameStore::new()),
284 )
285 }
286}
287
288impl<R, P, S, F> PjsAppState<R, P, S, F>
289where
290 R: StreamRepositoryGat + Send + Sync + 'static,
291 P: EventPublisherGat + Send + Sync + 'static,
292 S: StreamStoreGat + Send + Sync + 'static,
293 F: FrameStoreGat + Send + Sync + 'static,
294{
295 pub fn with_stores(
298 repository: Arc<R>,
299 event_publisher: Arc<P>,
300 stream_store: Arc<S>,
301 dictionary_store: Arc<dyn DictionaryStore>,
302 frame_store: Arc<F>,
303 ) -> Self {
304 let started_at = Instant::now();
305 Self {
306 command_handler: Arc::new(SessionCommandHandler::with_stores(
307 repository.clone(),
308 event_publisher,
309 dictionary_store.clone(),
310 frame_store.clone(),
311 )),
312 session_query_handler: Arc::new(SessionQueryHandler::new(repository.clone())),
313 stream_query_handler: Arc::new(StreamQueryHandler::new(
314 repository.clone(),
315 stream_store,
316 frame_store,
317 )),
318 system_handler: Arc::new(SystemQueryHandler::with_start_time(repository, started_at)),
319 dictionary_store,
320 }
321 }
322}
323
324#[derive(Debug, Deserialize)]
330pub struct CreateSessionRequest {
331 pub max_concurrent_streams: Option<usize>,
333 pub timeout_seconds: Option<u64>,
335 pub client_info: Option<String>,
337}
338
339#[derive(Debug, Serialize)]
341pub struct CreateSessionResponse {
342 pub session_id: String,
344 pub expires_at: chrono::DateTime<chrono::Utc>,
346}
347
348#[derive(Debug, Deserialize)]
350pub struct StartStreamRequest {
351 pub data: JsonData,
356 pub priority_threshold: Option<u8>,
358 pub max_frames: Option<usize>,
360}
361
362#[derive(Debug, Deserialize)]
364pub struct StreamParams {
365 pub session_id: String,
367 pub priority: Option<u8>,
369 pub format: Option<String>,
371}
372
373#[derive(Debug, Default, Deserialize)]
385pub struct GenerateFramesRequest {
386 pub priority_threshold: Option<u8>,
388 pub max_frames: Option<usize>,
390}
391
392#[derive(Debug, Serialize)]
399pub struct GenerateFramesResponse {
400 pub frames: Vec<Frame>,
402 pub frame_count: usize,
404}
405
406#[derive(Debug, Serialize)]
408pub struct SessionHealthResponse {
409 pub is_healthy: bool,
411 pub active_streams: usize,
413 pub failed_streams: usize,
415 pub is_expired: bool,
417 pub uptime_seconds: i64,
419}
420
421impl From<SessionHealth> for SessionHealthResponse {
422 fn from(health: SessionHealth) -> Self {
423 Self {
424 is_healthy: health.is_healthy,
425 active_streams: health.active_streams,
426 failed_streams: health.failed_streams,
427 is_expired: health.is_expired,
428 uptime_seconds: health.uptime_seconds,
429 }
430 }
431}
432
433pub fn create_pjs_router<R, P, S>() -> Router<PjsAppState<R, P, S>>
446where
447 R: StreamRepositoryGat + Send + Sync + 'static,
448 P: EventPublisherGat + Send + Sync + 'static,
449 S: StreamStoreGat + Send + Sync + 'static,
450{
451 create_pjs_router_with_config::<R, P, S>(&HttpServerConfig::default())
452 .expect("default HttpServerConfig must always produce a valid CORS layer")
453}
454
455pub fn create_pjs_router_with_config<R, P, S>(
472 config: &HttpServerConfig,
473) -> Result<Router<PjsAppState<R, P, S>>, PjsError>
474where
475 R: StreamRepositoryGat + Send + Sync + 'static,
476 P: EventPublisherGat + Send + Sync + 'static,
477 S: StreamStoreGat + Send + Sync + 'static,
478{
479 let all_routes = public_routes::<R, P, S>().merge(protected_routes::<R, P, S>());
480 apply_common_layers(all_routes, config, None)
481}
482
483pub fn create_pjs_router_with_rate_limit<R, P, S>(
503 rate_limit_middleware: RateLimitMiddleware,
504) -> Router<PjsAppState<R, P, S>>
505where
506 R: StreamRepositoryGat + Send + Sync + 'static,
507 P: EventPublisherGat + Send + Sync + 'static,
508 S: StreamStoreGat + Send + Sync + 'static,
509{
510 create_pjs_router_with_rate_limit_and_config::<R, P, S>(
511 &HttpServerConfig::default(),
512 rate_limit_middleware,
513 )
514 .expect("default HttpServerConfig must always produce a valid CORS layer")
515}
516
517pub fn create_pjs_router_with_rate_limit_and_config<R, P, S>(
528 config: &HttpServerConfig,
529 rate_limit_middleware: RateLimitMiddleware,
530) -> Result<Router<PjsAppState<R, P, S>>, PjsError>
531where
532 R: StreamRepositoryGat + Send + Sync + 'static,
533 P: EventPublisherGat + Send + Sync + 'static,
534 S: StreamStoreGat + Send + Sync + 'static,
535{
536 let all_routes = public_routes::<R, P, S>().merge(protected_routes::<R, P, S>());
537 apply_common_layers(all_routes, config, Some(rate_limit_middleware))
538}
539
540#[cfg(feature = "http-server")]
564pub fn create_pjs_router_with_auth<R, P, S>(
565 config: &HttpServerConfig,
566 auth: crate::infrastructure::http::auth::ApiKeyAuthLayer,
567) -> Result<Router<PjsAppState<R, P, S>>, PjsError>
568where
569 R: StreamRepositoryGat + Send + Sync + 'static,
570 P: EventPublisherGat + Send + Sync + 'static,
571 S: StreamStoreGat + Send + Sync + 'static,
572{
573 let protected = protected_routes::<R, P, S>().layer(auth);
576 let merged = public_routes::<R, P, S>().merge(protected);
577 apply_common_layers(merged, config, None)
578}
579
580#[cfg(feature = "http-server")]
614pub fn create_pjs_router_with_rate_limit_and_auth<R, P, S>(
615 config: &HttpServerConfig,
616 rate_limit: RateLimitMiddleware,
617 auth: crate::infrastructure::http::auth::ApiKeyAuthLayer,
618) -> Result<Router<PjsAppState<R, P, S>>, PjsError>
619where
620 R: StreamRepositoryGat + Send + Sync + 'static,
621 P: EventPublisherGat + Send + Sync + 'static,
622 S: StreamStoreGat + Send + Sync + 'static,
623{
624 let protected = protected_routes::<R, P, S>().layer(auth);
625 let merged = public_routes::<R, P, S>().merge(protected);
626 apply_common_layers(merged, config, Some(rate_limit))
627}
628
629fn public_routes<R, P, S>() -> Router<PjsAppState<R, P, S>>
635where
636 R: StreamRepositoryGat + Send + Sync + 'static,
637 P: EventPublisherGat + Send + Sync + 'static,
638 S: StreamStoreGat + Send + Sync + 'static,
639{
640 let router = Router::new().route("/pjs/health", get(system_health));
641
642 #[cfg(feature = "metrics")]
643 let router = router.route(
644 "/metrics",
645 get(crate::infrastructure::http::metrics::metrics_handler),
646 );
647
648 router
649}
650
651fn protected_routes<R, P, S>() -> Router<PjsAppState<R, P, S>>
653where
654 R: StreamRepositoryGat + Send + Sync + 'static,
655 P: EventPublisherGat + Send + Sync + 'static,
656 S: StreamStoreGat + Send + Sync + 'static,
657{
658 let router = Router::new()
659 .route("/pjs/sessions", post(create_session::<R, P, S>))
660 .route("/pjs/sessions/{session_id}", get(get_session::<R, P, S>))
661 .route(
662 "/pjs/sessions/{session_id}/health",
663 get(session_health::<R, P, S>),
664 )
665 .route(
666 "/pjs/sessions/{session_id}/stats",
667 get(get_session_stats::<R, P, S>),
668 )
669 .route(
670 "/pjs/sessions/{session_id}/streams",
671 post(create_stream::<R, P, S>),
672 )
673 .route(
674 "/pjs/sessions/{session_id}/streams/{stream_id}/start",
675 post(start_stream::<R, P, S>),
676 )
677 .route(
678 "/pjs/sessions/{session_id}/streams/{stream_id}/generate-frames",
679 post(generate_frames::<R, P, S>),
680 )
681 .route(
682 "/pjs/sessions/{session_id}/streams/{stream_id}",
683 get(get_stream::<R, P, S>),
684 )
685 .route(
686 "/pjs/sessions/{session_id}/streams/{stream_id}/frames",
687 get(get_stream_frames::<R, P, S>),
688 )
689 .route(
690 "/pjs/sessions/{session_id}/streams/{stream_id}/frames/stream",
691 get(stream_stream_frames::<R, P, S>),
692 )
693 .route("/pjs/sessions/search", get(search_sessions::<R, P, S>))
694 .route("/pjs/sessions", get(list_sessions::<R, P, S>))
695 .route("/pjs/stats", get(get_system_stats::<R, P, S>));
696
697 #[cfg(all(feature = "compression", not(target_arch = "wasm32")))]
698 let router = router.route(
699 "/pjs/sessions/{session_id}/dictionary",
700 get(get_session_dictionary::<R, P, S>),
701 );
702
703 router
704}
705
706const MAX_CONCURRENT_REQUESTS: usize = 512;
734
735const REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
748
749const RESPONSE_BODY_IDLE_TIMEOUT: Duration = Duration::from_secs(60);
775
776fn apply_common_layers<R, P, S>(
821 router: Router<PjsAppState<R, P, S>>,
822 config: &HttpServerConfig,
823 rate_limit: Option<RateLimitMiddleware>,
824) -> Result<Router<PjsAppState<R, P, S>>, PjsError>
825where
826 R: StreamRepositoryGat + Send + Sync + 'static,
827 P: EventPublisherGat + Send + Sync + 'static,
828 S: StreamStoreGat + Send + Sync + 'static,
829{
830 let cors = build_cors_layer(config)?;
831 let router = router.layer(GlobalConcurrencyLimitLayer::new(MAX_CONCURRENT_REQUESTS));
832 let router = match rate_limit {
833 Some(rate_limit) => router.layer(rate_limit),
834 None => router,
835 };
836 Ok(router
837 .layer(middleware::from_fn(security_middleware))
838 .layer(DefaultBodyLimit::max(10 * 1024 * 1024))
839 .layer(cors)
840 .layer(ResponseBodyTimeoutLayer::new(RESPONSE_BODY_IDLE_TIMEOUT))
841 .layer(TimeoutLayer::with_status_code(
842 StatusCode::REQUEST_TIMEOUT,
843 REQUEST_TIMEOUT,
844 ))
845 .layer(TraceLayer::new_for_http()))
846}
847
848pub(crate) fn parse_session_id(raw: String) -> Result<SessionId, PjsError> {
850 SessionId::from_string(&raw).map_err(|_| PjsError::InvalidSessionId(raw))
851}
852
853pub(crate) fn parse_session_and_stream_id(
856 session_raw: String,
857 stream_raw: String,
858) -> Result<(SessionId, StreamId), PjsError> {
859 let session_id = parse_session_id(session_raw)?;
860 let stream_id =
861 StreamId::from_string(&stream_raw).map_err(|_| PjsError::InvalidStreamId(stream_raw))?;
862 Ok((session_id, stream_id))
863}
864
865pub(crate) fn parse_session_state(raw: String) -> Result<SessionState, PjsError> {
876 serde_json::from_value(serde_json::Value::String(raw.clone()))
877 .map_err(|_| PjsError::InvalidSessionState(raw))
878}
879
880pub(crate) fn parse_sort_field(raw: String) -> Result<SessionSortField, PjsError> {
889 serde_json::from_value(serde_json::Value::String(raw.clone()))
890 .map_err(|_| PjsError::InvalidSortField(raw))
891}
892
893pub(crate) fn parse_sort_order(raw: String) -> Result<SortOrder, PjsError> {
903 serde_json::from_value(serde_json::Value::String(raw.clone()))
904 .map_err(|_| PjsError::InvalidSortOrder(raw))
905}
906
907#[derive(Debug, Deserialize)]
909pub struct PaginationParams {
910 pub limit: Option<usize>,
912 pub offset: Option<usize>,
914}
915
916#[derive(Debug, Deserialize)]
918pub struct SearchSessionsParams {
919 pub state: Option<String>,
927 pub sort_by: Option<String>,
936 pub sort_order: Option<String>,
945 pub limit: Option<usize>,
947 pub offset: Option<usize>,
949}
950
951#[derive(Debug, Deserialize)]
953pub struct FrameQueryParams {
954 pub since_sequence: Option<u64>,
956 pub priority: Option<u8>,
958 pub limit: Option<usize>,
960}
961
962#[derive(Debug, thiserror::Error)]
972pub enum PjsError {
973 #[error("Application error: {0}")]
975 Application(#[from] crate::application::ApplicationError),
976
977 #[error("Invalid session ID: {0}")]
979 InvalidSessionId(String),
980
981 #[error("Invalid stream ID: {0}")]
983 InvalidStreamId(String),
984
985 #[error("Invalid priority: {0}")]
987 InvalidPriority(String),
988
989 #[error(
991 "Invalid session state: {0} (expected one of: Initializing, Active, Closing, Completed, Failed)"
992 )]
993 InvalidSessionState(String),
994
995 #[error(
997 "Invalid sort field: {0} (expected one of: created_at, updated_at, stream_count, total_bytes)"
998 )]
999 InvalidSortField(String),
1000
1001 #[error("Invalid sort order: {0} (expected one of: asc, ascending, desc, descending)")]
1003 InvalidSortOrder(String),
1004
1005 #[error("HTTP error: {0}")]
1028 HttpError(String),
1029}
1030
1031impl IntoResponse for PjsError {
1032 fn into_response(self) -> Response {
1033 let (status, error_message) = match &self {
1034 PjsError::Application(app_err) => {
1035 use crate::application::ApplicationError;
1036 let status = match app_err {
1037 ApplicationError::NotFound(_) => StatusCode::NOT_FOUND,
1038 ApplicationError::Validation(_) => StatusCode::BAD_REQUEST,
1039 ApplicationError::Authorization(_) => StatusCode::UNAUTHORIZED,
1040 ApplicationError::Concurrency(_) | ApplicationError::Conflict(_) => {
1041 StatusCode::CONFLICT
1042 }
1043 ApplicationError::Domain(_) | ApplicationError::Logic(_) => {
1044 StatusCode::INTERNAL_SERVER_ERROR
1045 }
1046 };
1047 (status, self.to_string())
1048 }
1049 PjsError::InvalidSessionId(_) => (StatusCode::BAD_REQUEST, self.to_string()),
1050 PjsError::InvalidStreamId(_) => (StatusCode::BAD_REQUEST, self.to_string()),
1051 PjsError::InvalidPriority(_) => (StatusCode::BAD_REQUEST, self.to_string()),
1052 PjsError::InvalidSessionState(_) => (StatusCode::BAD_REQUEST, self.to_string()),
1053 PjsError::InvalidSortField(_) => (StatusCode::BAD_REQUEST, self.to_string()),
1054 PjsError::InvalidSortOrder(_) => (StatusCode::BAD_REQUEST, self.to_string()),
1055 PjsError::HttpError(_) => (StatusCode::INTERNAL_SERVER_ERROR, self.to_string()),
1056 };
1057
1058 let body = Json(serde_json::json!({
1059 "error": error_message
1060 }));
1061
1062 (status, body).into_response()
1063 }
1064}
1065
1066#[cfg(test)]
1067mod tests {
1068 use super::*;
1069 use axum::http::header;
1070
1071 #[test]
1074 fn cors_empty_origins_denies_all() {
1075 let config = HttpServerConfig {
1076 allowed_origins: vec![],
1077 };
1078 let result = build_cors_layer(&config);
1080 assert!(
1081 result.is_ok(),
1082 "empty origins should return Ok (deny-all layer)"
1083 );
1084 }
1085
1086 #[test]
1087 fn cors_wildcard_only_is_ok() {
1088 let config = HttpServerConfig {
1089 allowed_origins: vec!["*".to_string()],
1090 };
1091 let result = build_cors_layer(&config);
1092 assert!(result.is_ok(), "wildcard-only should return Ok");
1093 }
1094
1095 #[test]
1096 fn cors_mixed_wildcard_and_explicit_is_err() {
1097 let config = HttpServerConfig {
1098 allowed_origins: vec!["*".to_string(), "http://example.com".to_string()],
1099 };
1100 let result = build_cors_layer(&config);
1101 assert!(
1102 result.is_err(),
1103 "mixing wildcard with explicit origins must fail"
1104 );
1105 let msg = result.unwrap_err().to_string();
1106 assert!(
1107 msg.contains("wildcard"),
1108 "error message should mention wildcard: {msg}"
1109 );
1110 }
1111
1112 #[test]
1113 fn cors_valid_single_origin_is_ok() {
1114 let config = HttpServerConfig {
1115 allowed_origins: vec!["http://example.com".to_string()],
1116 };
1117 assert!(build_cors_layer(&config).is_ok());
1118 }
1119
1120 #[test]
1121 fn cors_valid_multiple_origins_is_ok() {
1122 let config = HttpServerConfig {
1123 allowed_origins: vec![
1124 "https://app.example.com".to_string(),
1125 "https://admin.example.com".to_string(),
1126 ],
1127 };
1128 assert!(build_cors_layer(&config).is_ok());
1129 }
1130
1131 #[test]
1132 fn cors_invalid_origin_string_is_err() {
1133 let config = HttpServerConfig {
1134 allowed_origins: vec!["not a\nvalid header".to_string()],
1136 };
1137 let result = build_cors_layer(&config);
1138 assert!(result.is_err(), "invalid origin string must return Err");
1139 }
1140
1141 #[test]
1142 fn default_config_is_valid() {
1143 assert!(
1146 build_cors_layer(&HttpServerConfig::default()).is_ok(),
1147 "default HttpServerConfig must produce a valid CORS layer"
1148 );
1149 }
1150
1151 #[test]
1154 fn parse_session_id_valid_roundtrips() {
1155 let id = SessionId::new();
1156 let parsed = parse_session_id(id.to_string()).expect("valid uuid must parse");
1157 assert_eq!(parsed, id);
1158 }
1159
1160 #[test]
1161 fn parse_session_id_invalid_returns_invalid_session_id_error() {
1162 let raw = "not-a-valid-uuid".to_string();
1163 let err = parse_session_id(raw.clone()).unwrap_err();
1164 match err {
1165 PjsError::InvalidSessionId(msg) => assert_eq!(msg, raw),
1166 other => panic!("expected InvalidSessionId, got {other:?}"),
1167 }
1168 }
1169
1170 #[test]
1171 fn parse_session_and_stream_id_valid_roundtrips() {
1172 let session_id = SessionId::new();
1173 let stream_id = StreamId::new();
1174 let (parsed_session, parsed_stream) =
1175 parse_session_and_stream_id(session_id.to_string(), stream_id.to_string())
1176 .expect("valid uuids must parse");
1177 assert_eq!(parsed_session, session_id);
1178 assert_eq!(parsed_stream, stream_id);
1179 }
1180
1181 #[test]
1182 fn parse_session_and_stream_id_invalid_session_short_circuits() {
1183 let raw_session = "bad-session".to_string();
1184 let err = parse_session_and_stream_id(raw_session.clone(), StreamId::new().to_string())
1185 .unwrap_err();
1186 match err {
1187 PjsError::InvalidSessionId(msg) => assert_eq!(msg, raw_session),
1188 other => panic!("expected InvalidSessionId, got {other:?}"),
1189 }
1190 }
1191
1192 #[test]
1193 fn parse_session_and_stream_id_invalid_stream_returns_invalid_stream_id_error() {
1194 let raw_stream = "bad-stream".to_string();
1195 let err = parse_session_and_stream_id(SessionId::new().to_string(), raw_stream.clone())
1196 .unwrap_err();
1197 match err {
1198 PjsError::InvalidStreamId(msg) => assert_eq!(msg, raw_stream),
1199 other => panic!("expected InvalidStreamId, got {other:?}"),
1200 }
1201 }
1202
1203 use crate::domain::{
1206 entities::Stream,
1207 events::DomainEvent,
1208 ports::{
1209 EventPublisherGat, PriorityDistribution, StreamFilter, StreamStatistics, StreamStatus,
1210 StreamStoreGat,
1211 },
1212 value_objects::{SessionId, StreamId},
1213 };
1214 use crate::test_support::MockRepository;
1215 use chrono::Utc;
1216
1217 struct MockEventPublisher;
1218
1219 impl EventPublisherGat for MockEventPublisher {
1220 type PublishFuture<'a>
1221 = impl std::future::Future<Output = crate::domain::DomainResult<()>> + Send + 'a
1222 where
1223 Self: 'a;
1224
1225 type PublishBatchFuture<'a>
1226 = impl std::future::Future<Output = crate::domain::DomainResult<()>> + Send + 'a
1227 where
1228 Self: 'a;
1229
1230 fn publish(&self, _event: DomainEvent) -> Self::PublishFuture<'_> {
1231 async move { Ok(()) }
1232 }
1233
1234 fn publish_batch(&self, _events: Vec<DomainEvent>) -> Self::PublishBatchFuture<'_> {
1235 async move { Ok(()) }
1236 }
1237 }
1238
1239 struct MockStreamStore;
1240
1241 impl StreamStoreGat for MockStreamStore {
1242 type StoreStreamFuture<'a>
1243 = impl std::future::Future<Output = crate::domain::DomainResult<()>> + Send + 'a
1244 where
1245 Self: 'a;
1246
1247 type GetStreamFuture<'a>
1248 = impl std::future::Future<Output = crate::domain::DomainResult<Option<Stream>>>
1249 + Send
1250 + 'a
1251 where
1252 Self: 'a;
1253
1254 type DeleteStreamFuture<'a>
1255 = impl std::future::Future<Output = crate::domain::DomainResult<()>> + Send + 'a
1256 where
1257 Self: 'a;
1258
1259 type ListStreamsForSessionFuture<'a>
1260 =
1261 impl std::future::Future<Output = crate::domain::DomainResult<Vec<Stream>>> + Send + 'a
1262 where
1263 Self: 'a;
1264
1265 type FindStreamsBySessionFuture<'a>
1266 =
1267 impl std::future::Future<Output = crate::domain::DomainResult<Vec<Stream>>> + Send + 'a
1268 where
1269 Self: 'a;
1270
1271 type UpdateStreamStatusFuture<'a>
1272 = impl std::future::Future<Output = crate::domain::DomainResult<()>> + Send + 'a
1273 where
1274 Self: 'a;
1275
1276 type GetStreamStatisticsFuture<'a>
1277 = impl std::future::Future<Output = crate::domain::DomainResult<StreamStatistics>>
1278 + Send
1279 + 'a
1280 where
1281 Self: 'a;
1282
1283 fn store_stream(&self, _stream: Stream) -> Self::StoreStreamFuture<'_> {
1284 async move { Ok(()) }
1285 }
1286
1287 fn get_stream(&self, _stream_id: StreamId) -> Self::GetStreamFuture<'_> {
1288 async move { Ok(None) }
1289 }
1290
1291 fn delete_stream(&self, _stream_id: StreamId) -> Self::DeleteStreamFuture<'_> {
1292 async move { Ok(()) }
1293 }
1294
1295 fn list_streams_for_session(
1296 &self,
1297 _session_id: SessionId,
1298 ) -> Self::ListStreamsForSessionFuture<'_> {
1299 async move { Ok(vec![]) }
1300 }
1301
1302 fn find_streams_by_session(
1303 &self,
1304 _session_id: SessionId,
1305 _filter: StreamFilter,
1306 ) -> Self::FindStreamsBySessionFuture<'_> {
1307 async move { Ok(vec![]) }
1308 }
1309
1310 fn update_stream_status(
1311 &self,
1312 _stream_id: StreamId,
1313 _status: StreamStatus,
1314 ) -> Self::UpdateStreamStatusFuture<'_> {
1315 async move { Ok(()) }
1316 }
1317
1318 fn get_stream_statistics(
1319 &self,
1320 _stream_id: StreamId,
1321 ) -> Self::GetStreamStatisticsFuture<'_> {
1322 async move {
1323 Ok(StreamStatistics {
1324 total_frames: 0,
1325 total_bytes: 0,
1326 priority_distribution: PriorityDistribution::default(),
1327 avg_frame_size: 0.0,
1328 creation_time: Utc::now(),
1329 completion_time: None,
1330 processing_duration: None,
1331 })
1332 }
1333 }
1334 }
1335
1336 #[tokio::test]
1337 async fn test_system_health() {
1338 let response = system_health().await;
1339 let health_data: serde_json::Value = response.0;
1340
1341 assert_eq!(health_data["status"], "healthy");
1342 assert!(!health_data["features"].as_array().unwrap().is_empty());
1343 }
1344
1345 #[tokio::test]
1346 async fn test_app_state_creation() {
1347 let repository = Arc::new(MockRepository::new());
1348 let event_publisher = Arc::new(MockEventPublisher);
1349 let stream_store = Arc::new(MockStreamStore);
1350
1351 let _state = PjsAppState::new(repository, event_publisher, stream_store);
1352 }
1353
1354 #[tokio::test]
1355 async fn test_get_system_stats_returns_real_uptime() {
1356 use crate::application::handlers::QueryHandlerGat;
1357 use crate::application::handlers::query_handlers::SystemQueryHandler;
1358 use crate::application::queries::GetSystemStatsQuery;
1359 use std::time::{Duration, Instant};
1360
1361 let repository = Arc::new(MockRepository::new());
1362 let started_at = Instant::now() - Duration::from_secs(5);
1364 let handler = SystemQueryHandler::with_start_time(repository, started_at);
1365
1366 let query = GetSystemStatsQuery {
1367 include_historical: false,
1368 };
1369 let result = QueryHandlerGat::handle(&handler, query).await.unwrap();
1370
1371 assert!(
1373 result.uptime_seconds >= 5,
1374 "uptime_seconds should be at least 5, got {}",
1375 result.uptime_seconds
1376 );
1377 assert_ne!(
1379 result.uptime_seconds, 3600,
1380 "uptime_seconds must not be the hard-coded placeholder 3600"
1381 );
1382 }
1383
1384 #[cfg(feature = "metrics")]
1385 #[tokio::test]
1386 async fn test_metrics_endpoint_returns_prometheus_format() {
1387 use crate::infrastructure::http::metrics::install_global_recorder;
1388
1389 let handle = install_global_recorder().expect("recorder install should succeed");
1391 let rendered = handle.render();
1392 assert!(
1395 !rendered.contains("{\"error\""),
1396 "rendered metrics should not be a JSON error: {rendered}"
1397 );
1398
1399 let handle2 = install_global_recorder().expect("second call must not fail");
1401 assert_eq!(
1402 handle.render(),
1403 handle2.render(),
1404 "both handles must render the same metrics"
1405 );
1406 }
1407
1408 #[cfg(feature = "metrics")]
1409 #[test]
1410 fn test_metrics_router_has_metrics_route() {
1411 let _router =
1414 create_pjs_router_with_config::<MockRepository, MockEventPublisher, MockStreamStore>(
1415 &HttpServerConfig::default(),
1416 )
1417 .expect("router should build successfully with metrics feature");
1418 }
1419
1420 #[tokio::test]
1426 async fn search_sessions_route_returns_ok() {
1427 use axum::http::Request;
1428 use tower::ServiceExt;
1429
1430 let repository = Arc::new(MockRepository::new());
1431 let event_publisher = Arc::new(MockEventPublisher);
1432 let stream_store = Arc::new(MockStreamStore);
1433 let state = PjsAppState::new(repository, event_publisher, stream_store);
1434
1435 let router =
1436 create_pjs_router_with_config::<MockRepository, MockEventPublisher, MockStreamStore>(
1437 &HttpServerConfig::default(),
1438 )
1439 .expect("router should build")
1440 .with_state(state);
1441
1442 let req = Request::builder()
1443 .uri("/pjs/sessions/search")
1444 .body(axum::body::Body::empty())
1445 .unwrap();
1446
1447 let resp = router.oneshot(req).await.unwrap();
1448 assert_eq!(resp.status(), StatusCode::OK);
1449 }
1450
1451 #[tokio::test]
1455 async fn search_sessions_route_accepts_valid_state() {
1456 use axum::http::Request;
1457 use tower::ServiceExt;
1458
1459 let repository = Arc::new(MockRepository::new());
1460 let event_publisher = Arc::new(MockEventPublisher);
1461 let stream_store = Arc::new(MockStreamStore);
1462 let state = PjsAppState::new(repository, event_publisher, stream_store);
1463
1464 let router =
1465 create_pjs_router_with_config::<MockRepository, MockEventPublisher, MockStreamStore>(
1466 &HttpServerConfig::default(),
1467 )
1468 .expect("router should build")
1469 .with_state(state);
1470
1471 let req = Request::builder()
1472 .uri("/pjs/sessions/search?state=Active")
1473 .body(axum::body::Body::empty())
1474 .unwrap();
1475
1476 let resp = router.oneshot(req).await.unwrap();
1477 assert_eq!(resp.status(), StatusCode::OK);
1478 }
1479
1480 #[tokio::test]
1488 async fn search_sessions_route_rejects_unknown_state() {
1489 use axum::body::to_bytes;
1490 use axum::http::Request;
1491 use tower::ServiceExt;
1492
1493 let repository = Arc::new(MockRepository::new());
1494 let event_publisher = Arc::new(MockEventPublisher);
1495 let stream_store = Arc::new(MockStreamStore);
1496 let state = PjsAppState::new(repository, event_publisher, stream_store);
1497
1498 let router =
1499 create_pjs_router_with_config::<MockRepository, MockEventPublisher, MockStreamStore>(
1500 &HttpServerConfig::default(),
1501 )
1502 .expect("router should build")
1503 .with_state(state);
1504
1505 let req = Request::builder()
1506 .uri("/pjs/sessions/search?state=active")
1507 .body(axum::body::Body::empty())
1508 .unwrap();
1509
1510 let resp = router.oneshot(req).await.unwrap();
1511 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
1512
1513 let content_type = resp
1514 .headers()
1515 .get(header::CONTENT_TYPE)
1516 .and_then(|v| v.to_str().ok())
1517 .unwrap_or_default()
1518 .to_string();
1519 assert!(
1520 content_type.starts_with("application/json"),
1521 "rejection must use the API's JSON envelope, got content-type: {content_type}"
1522 );
1523
1524 let body = to_bytes(resp.into_body(), usize::MAX).await.unwrap();
1525 let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
1526 assert!(
1527 json.get("error").is_some_and(|e| e.is_string()),
1528 "body must match the standard {{\"error\": ...}} envelope, got: {json}"
1529 );
1530 }
1531
1532 #[tokio::test]
1537 async fn search_sessions_route_accepts_valid_sort_by() {
1538 use axum::http::Request;
1539 use tower::ServiceExt;
1540
1541 let repository = Arc::new(MockRepository::new());
1542 let event_publisher = Arc::new(MockEventPublisher);
1543 let stream_store = Arc::new(MockStreamStore);
1544 let state = PjsAppState::new(repository, event_publisher, stream_store);
1545
1546 let router =
1547 create_pjs_router_with_config::<MockRepository, MockEventPublisher, MockStreamStore>(
1548 &HttpServerConfig::default(),
1549 )
1550 .expect("router should build")
1551 .with_state(state);
1552
1553 let req = Request::builder()
1554 .uri("/pjs/sessions/search?sort_by=created_at")
1555 .body(axum::body::Body::empty())
1556 .unwrap();
1557
1558 let resp = router.oneshot(req).await.unwrap();
1559 assert_eq!(resp.status(), StatusCode::OK);
1560 }
1561
1562 #[tokio::test]
1567 async fn search_sessions_route_rejects_unknown_sort_by() {
1568 use axum::body::to_bytes;
1569 use axum::http::Request;
1570 use tower::ServiceExt;
1571
1572 let repository = Arc::new(MockRepository::new());
1573 let event_publisher = Arc::new(MockEventPublisher);
1574 let stream_store = Arc::new(MockStreamStore);
1575 let state = PjsAppState::new(repository, event_publisher, stream_store);
1576
1577 let router =
1578 create_pjs_router_with_config::<MockRepository, MockEventPublisher, MockStreamStore>(
1579 &HttpServerConfig::default(),
1580 )
1581 .expect("router should build")
1582 .with_state(state);
1583
1584 let req = Request::builder()
1585 .uri("/pjs/sessions/search?sort_by=bogus")
1586 .body(axum::body::Body::empty())
1587 .unwrap();
1588
1589 let resp = router.oneshot(req).await.unwrap();
1590 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
1591
1592 let content_type = resp
1593 .headers()
1594 .get(header::CONTENT_TYPE)
1595 .and_then(|v| v.to_str().ok())
1596 .unwrap_or_default()
1597 .to_string();
1598 assert!(
1599 content_type.starts_with("application/json"),
1600 "rejection must use the API's JSON envelope, got content-type: {content_type}"
1601 );
1602
1603 let body = to_bytes(resp.into_body(), usize::MAX).await.unwrap();
1604 let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
1605 assert!(
1606 json.get("error").is_some_and(|e| e.is_string()),
1607 "body must match the standard {{\"error\": ...}} envelope, got: {json}"
1608 );
1609 }
1610
1611 #[tokio::test]
1617 async fn search_sessions_route_rejects_sort_by_missing_underscore() {
1618 use axum::http::Request;
1619 use tower::ServiceExt;
1620
1621 let repository = Arc::new(MockRepository::new());
1622 let event_publisher = Arc::new(MockEventPublisher);
1623 let stream_store = Arc::new(MockStreamStore);
1624 let state = PjsAppState::new(repository, event_publisher, stream_store);
1625
1626 let router =
1627 create_pjs_router_with_config::<MockRepository, MockEventPublisher, MockStreamStore>(
1628 &HttpServerConfig::default(),
1629 )
1630 .expect("router should build")
1631 .with_state(state);
1632
1633 let req = Request::builder()
1634 .uri("/pjs/sessions/search?sort_by=createdat")
1635 .body(axum::body::Body::empty())
1636 .unwrap();
1637
1638 let resp = router.oneshot(req).await.unwrap();
1639 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
1640 }
1641
1642 #[tokio::test]
1647 async fn search_sessions_route_rejects_empty_sort_by() {
1648 use axum::http::Request;
1649 use tower::ServiceExt;
1650
1651 let repository = Arc::new(MockRepository::new());
1652 let event_publisher = Arc::new(MockEventPublisher);
1653 let stream_store = Arc::new(MockStreamStore);
1654 let state = PjsAppState::new(repository, event_publisher, stream_store);
1655
1656 let router =
1657 create_pjs_router_with_config::<MockRepository, MockEventPublisher, MockStreamStore>(
1658 &HttpServerConfig::default(),
1659 )
1660 .expect("router should build")
1661 .with_state(state);
1662
1663 let req = Request::builder()
1664 .uri("/pjs/sessions/search?sort_by=")
1665 .body(axum::body::Body::empty())
1666 .unwrap();
1667
1668 let resp = router.oneshot(req).await.unwrap();
1669 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
1670 }
1671
1672 #[tokio::test]
1676 async fn search_sessions_route_accepts_valid_sort_order() {
1677 use axum::http::Request;
1678 use tower::ServiceExt;
1679
1680 let repository = Arc::new(MockRepository::new());
1681 let event_publisher = Arc::new(MockEventPublisher);
1682 let stream_store = Arc::new(MockStreamStore);
1683 let state = PjsAppState::new(repository, event_publisher, stream_store);
1684
1685 let router =
1686 create_pjs_router_with_config::<MockRepository, MockEventPublisher, MockStreamStore>(
1687 &HttpServerConfig::default(),
1688 )
1689 .expect("router should build")
1690 .with_state(state);
1691
1692 for value in ["asc", "ascending", "desc", "descending"] {
1693 let req = Request::builder()
1694 .uri(format!("/pjs/sessions/search?sort_order={value}"))
1695 .body(axum::body::Body::empty())
1696 .unwrap();
1697
1698 let resp = router.clone().oneshot(req).await.unwrap();
1699 assert_eq!(
1700 resp.status(),
1701 StatusCode::OK,
1702 "value {value} should be accepted"
1703 );
1704 }
1705 }
1706
1707 #[tokio::test]
1712 async fn search_sessions_route_rejects_unknown_sort_order() {
1713 use axum::body::to_bytes;
1714 use axum::http::Request;
1715 use tower::ServiceExt;
1716
1717 let repository = Arc::new(MockRepository::new());
1718 let event_publisher = Arc::new(MockEventPublisher);
1719 let stream_store = Arc::new(MockStreamStore);
1720 let state = PjsAppState::new(repository, event_publisher, stream_store);
1721
1722 let router =
1723 create_pjs_router_with_config::<MockRepository, MockEventPublisher, MockStreamStore>(
1724 &HttpServerConfig::default(),
1725 )
1726 .expect("router should build")
1727 .with_state(state);
1728
1729 let req = Request::builder()
1730 .uri("/pjs/sessions/search?sort_order=bogus")
1731 .body(axum::body::Body::empty())
1732 .unwrap();
1733
1734 let resp = router.oneshot(req).await.unwrap();
1735 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
1736
1737 let content_type = resp
1738 .headers()
1739 .get(header::CONTENT_TYPE)
1740 .and_then(|v| v.to_str().ok())
1741 .unwrap_or_default()
1742 .to_string();
1743 assert!(
1744 content_type.starts_with("application/json"),
1745 "rejection must use the API's JSON envelope, got content-type: {content_type}"
1746 );
1747
1748 let body = to_bytes(resp.into_body(), usize::MAX).await.unwrap();
1749 let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
1750 assert!(
1751 json.get("error").is_some_and(|e| e.is_string()),
1752 "body must match the standard {{\"error\": ...}} envelope, got: {json}"
1753 );
1754 }
1755
1756 #[tokio::test]
1761 async fn search_sessions_route_rejects_empty_sort_order() {
1762 use axum::http::Request;
1763 use tower::ServiceExt;
1764
1765 let repository = Arc::new(MockRepository::new());
1766 let event_publisher = Arc::new(MockEventPublisher);
1767 let stream_store = Arc::new(MockStreamStore);
1768 let state = PjsAppState::new(repository, event_publisher, stream_store);
1769
1770 let router =
1771 create_pjs_router_with_config::<MockRepository, MockEventPublisher, MockStreamStore>(
1772 &HttpServerConfig::default(),
1773 )
1774 .expect("router should build")
1775 .with_state(state);
1776
1777 let req = Request::builder()
1778 .uri("/pjs/sessions/search?sort_order=")
1779 .body(axum::body::Body::empty())
1780 .unwrap();
1781
1782 let resp = router.oneshot(req).await.unwrap();
1783 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
1784 }
1785
1786 #[tokio::test]
1790 async fn search_sessions_route_rejects_sort_order_typo() {
1791 use axum::http::Request;
1792 use tower::ServiceExt;
1793
1794 let repository = Arc::new(MockRepository::new());
1795 let event_publisher = Arc::new(MockEventPublisher);
1796 let stream_store = Arc::new(MockStreamStore);
1797 let state = PjsAppState::new(repository, event_publisher, stream_store);
1798
1799 let router =
1800 create_pjs_router_with_config::<MockRepository, MockEventPublisher, MockStreamStore>(
1801 &HttpServerConfig::default(),
1802 )
1803 .expect("router should build")
1804 .with_state(state);
1805
1806 let req = Request::builder()
1807 .uri("/pjs/sessions/search?sort_order=decs")
1808 .body(axum::body::Body::empty())
1809 .unwrap();
1810
1811 let resp = router.oneshot(req).await.unwrap();
1812 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
1813 }
1814
1815 #[tokio::test]
1819 async fn search_sessions_route_rejects_uppercase_sort_order() {
1820 use axum::http::Request;
1821 use tower::ServiceExt;
1822
1823 let repository = Arc::new(MockRepository::new());
1824 let event_publisher = Arc::new(MockEventPublisher);
1825 let stream_store = Arc::new(MockStreamStore);
1826 let state = PjsAppState::new(repository, event_publisher, stream_store);
1827
1828 let router =
1829 create_pjs_router_with_config::<MockRepository, MockEventPublisher, MockStreamStore>(
1830 &HttpServerConfig::default(),
1831 )
1832 .expect("router should build")
1833 .with_state(state);
1834
1835 let req = Request::builder()
1836 .uri("/pjs/sessions/search?sort_order=ASC")
1837 .body(axum::body::Body::empty())
1838 .unwrap();
1839
1840 let resp = router.oneshot(req).await.unwrap();
1841 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
1842 }
1843
1844 #[tokio::test]
1853 async fn generate_frames_route_dispatches_command_end_to_end() {
1854 use axum::body::to_bytes;
1855 use axum::http::{Method, Request};
1856 use tower::ServiceExt;
1857
1858 let repository = Arc::new(MockRepository::new());
1859 let event_publisher = Arc::new(MockEventPublisher);
1860 let stream_store = Arc::new(MockStreamStore);
1861 let state = PjsAppState::new(repository, event_publisher, stream_store);
1862
1863 let router =
1864 create_pjs_router_with_config::<MockRepository, MockEventPublisher, MockStreamStore>(
1865 &HttpServerConfig::default(),
1866 )
1867 .expect("router should build")
1868 .with_state(state);
1869
1870 let create_session = Request::builder()
1871 .method(Method::POST)
1872 .uri("/pjs/sessions")
1873 .header(header::CONTENT_TYPE, "application/json")
1874 .body(axum::body::Body::from("{}"))
1875 .unwrap();
1876 let resp = router.clone().oneshot(create_session).await.unwrap();
1877 assert_eq!(resp.status(), StatusCode::OK);
1878 let body = to_bytes(resp.into_body(), usize::MAX).await.unwrap();
1879 let session: serde_json::Value = serde_json::from_slice(&body).unwrap();
1880 let session_id = session["session_id"].as_str().unwrap().to_string();
1881
1882 let create_stream = Request::builder()
1883 .method(Method::POST)
1884 .uri(format!("/pjs/sessions/{session_id}/streams"))
1885 .header(header::CONTENT_TYPE, "application/json")
1886 .body(axum::body::Body::from(
1887 serde_json::json!({ "data": { "items": [1, 2, 3] } }).to_string(),
1888 ))
1889 .unwrap();
1890 let resp = router.clone().oneshot(create_stream).await.unwrap();
1891 assert_eq!(resp.status(), StatusCode::OK);
1892 let body = to_bytes(resp.into_body(), usize::MAX).await.unwrap();
1893 let stream: serde_json::Value = serde_json::from_slice(&body).unwrap();
1894 let stream_id = stream["stream_id"].as_str().unwrap().to_string();
1895
1896 let start = Request::builder()
1897 .method(Method::POST)
1898 .uri(format!(
1899 "/pjs/sessions/{session_id}/streams/{stream_id}/start"
1900 ))
1901 .body(axum::body::Body::empty())
1902 .unwrap();
1903 let resp = router.clone().oneshot(start).await.unwrap();
1904 assert_eq!(resp.status(), StatusCode::OK);
1905
1906 let generate = Request::builder()
1907 .method(Method::POST)
1908 .uri(format!(
1909 "/pjs/sessions/{session_id}/streams/{stream_id}/generate-frames"
1910 ))
1911 .header(header::CONTENT_TYPE, "application/json")
1912 .body(axum::body::Body::from(
1913 serde_json::json!({ "max_frames": 4 }).to_string(),
1914 ))
1915 .unwrap();
1916 let resp = router.oneshot(generate).await.unwrap();
1917 assert_eq!(
1918 resp.status(),
1919 StatusCode::OK,
1920 "POST .../generate-frames must be reachable end-to-end"
1921 );
1922 let body = to_bytes(resp.into_body(), usize::MAX).await.unwrap();
1923 let payload: serde_json::Value = serde_json::from_slice(&body).unwrap();
1924 assert!(payload["frames"].is_array(), "response must carry frames[]");
1925 let frame_count = payload["frame_count"]
1926 .as_u64()
1927 .expect("response must carry numeric frame_count");
1928 assert!(
1929 frame_count > 0,
1930 "extract_patches must yield at least one patch frame for `{{\"items\": [1,2,3]}}` \
1931 — frame_count was {frame_count}"
1932 );
1933 }
1934
1935 #[cfg(all(feature = "compression", not(target_arch = "wasm32")))]
1940 #[tokio::test]
1941 async fn dictionary_endpoint_becomes_reachable_after_training() {
1942 use crate::compression::zstd::N_TRAIN;
1943 use crate::infrastructure::repositories::InMemoryDictionaryStore;
1944 use crate::security::CompressionBombDetector;
1945 use axum::body::to_bytes;
1946 use axum::http::{Method, Request};
1947 use tower::ServiceExt;
1948
1949 let repository = Arc::new(MockRepository::new());
1950 let event_publisher = Arc::new(MockEventPublisher);
1951 let stream_store = Arc::new(MockStreamStore);
1952 let dictionary_store = Arc::new(InMemoryDictionaryStore::new(
1953 Arc::new(CompressionBombDetector::default()),
1954 64 * 1024,
1955 ));
1956 let state = PjsAppState::with_dictionary_store(
1957 repository,
1958 event_publisher,
1959 stream_store,
1960 dictionary_store,
1961 );
1962
1963 let router =
1964 create_pjs_router_with_config::<MockRepository, MockEventPublisher, MockStreamStore>(
1965 &HttpServerConfig::default(),
1966 )
1967 .expect("router should build")
1968 .with_state(state);
1969
1970 let create_session = Request::builder()
1971 .method(Method::POST)
1972 .uri("/pjs/sessions")
1973 .header(header::CONTENT_TYPE, "application/json")
1974 .body(axum::body::Body::from("{}"))
1975 .unwrap();
1976 let resp = router.clone().oneshot(create_session).await.unwrap();
1977 assert_eq!(resp.status(), StatusCode::OK);
1978 let body = to_bytes(resp.into_body(), usize::MAX).await.unwrap();
1979 let session: serde_json::Value = serde_json::from_slice(&body).unwrap();
1980 let session_id = session["session_id"].as_str().unwrap().to_string();
1981
1982 let mut payload = serde_json::Map::new();
1986 for i in 0..(N_TRAIN + 4) {
1987 payload.insert(
1988 format!("field_{i}"),
1989 serde_json::Value::String(format!("value_{i}")),
1990 );
1991 }
1992 let create_stream = Request::builder()
1993 .method(Method::POST)
1994 .uri(format!("/pjs/sessions/{session_id}/streams"))
1995 .header(header::CONTENT_TYPE, "application/json")
1996 .body(axum::body::Body::from(
1997 serde_json::json!({ "data": serde_json::Value::Object(payload) }).to_string(),
1998 ))
1999 .unwrap();
2000 let resp = router.clone().oneshot(create_stream).await.unwrap();
2001 assert_eq!(resp.status(), StatusCode::OK);
2002 let body = to_bytes(resp.into_body(), usize::MAX).await.unwrap();
2003 let stream: serde_json::Value = serde_json::from_slice(&body).unwrap();
2004 let stream_id = stream["stream_id"].as_str().unwrap().to_string();
2005
2006 let start = Request::builder()
2007 .method(Method::POST)
2008 .uri(format!(
2009 "/pjs/sessions/{session_id}/streams/{stream_id}/start"
2010 ))
2011 .body(axum::body::Body::empty())
2012 .unwrap();
2013 let resp = router.clone().oneshot(start).await.unwrap();
2014 assert_eq!(resp.status(), StatusCode::OK);
2015
2016 let dict_before = Request::builder()
2018 .method(Method::GET)
2019 .uri(format!("/pjs/sessions/{session_id}/dictionary"))
2020 .body(axum::body::Body::empty())
2021 .unwrap();
2022 let resp = router.clone().oneshot(dict_before).await.unwrap();
2023 assert_eq!(
2024 resp.status(),
2025 StatusCode::NOT_FOUND,
2026 "dictionary endpoint must be 404 before N_TRAIN samples accumulate"
2027 );
2028
2029 let max_frames = N_TRAIN + 4;
2032 let generate = Request::builder()
2033 .method(Method::POST)
2034 .uri(format!(
2035 "/pjs/sessions/{session_id}/streams/{stream_id}/generate-frames"
2036 ))
2037 .header(header::CONTENT_TYPE, "application/json")
2038 .body(axum::body::Body::from(
2039 serde_json::json!({ "max_frames": max_frames }).to_string(),
2040 ))
2041 .unwrap();
2042 let resp = router.clone().oneshot(generate).await.unwrap();
2043 assert_eq!(resp.status(), StatusCode::OK);
2044 let body = to_bytes(resp.into_body(), usize::MAX).await.unwrap();
2045 let payload: serde_json::Value = serde_json::from_slice(&body).unwrap();
2046 let frame_count = payload["frame_count"].as_u64().unwrap();
2047 assert!(
2048 frame_count >= N_TRAIN as u64,
2049 "single generate-frames call must yield at least N_TRAIN ({}) frames \
2050 so train_if_ready triggers training; got {frame_count}",
2051 N_TRAIN
2052 );
2053
2054 let dict_after = Request::builder()
2056 .method(Method::GET)
2057 .uri(format!("/pjs/sessions/{session_id}/dictionary"))
2058 .body(axum::body::Body::empty())
2059 .unwrap();
2060 let resp = router.oneshot(dict_after).await.unwrap();
2061 assert_eq!(
2062 resp.status(),
2063 StatusCode::OK,
2064 "dictionary endpoint must transition to 200 OK once N_TRAIN samples have been fed"
2065 );
2066 let body = to_bytes(resp.into_body(), usize::MAX).await.unwrap();
2067 assert!(
2068 !body.is_empty(),
2069 "trained dictionary body must be non-empty"
2070 );
2071 }
2072
2073 #[tokio::test]
2077 async fn generate_frames_route_rejects_invalid_priority() {
2078 use axum::http::{Method, Request};
2079 use tower::ServiceExt;
2080
2081 let repository = Arc::new(MockRepository::new());
2082 let event_publisher = Arc::new(MockEventPublisher);
2083 let stream_store = Arc::new(MockStreamStore);
2084 let state = PjsAppState::new(repository, event_publisher, stream_store);
2085
2086 let router =
2087 create_pjs_router_with_config::<MockRepository, MockEventPublisher, MockStreamStore>(
2088 &HttpServerConfig::default(),
2089 )
2090 .expect("router should build")
2091 .with_state(state);
2092
2093 let sid = SessionId::new();
2094 let stream_id = StreamId::new();
2095 let req = Request::builder()
2096 .method(Method::POST)
2097 .uri(format!(
2098 "/pjs/sessions/{sid}/streams/{stream_id}/generate-frames"
2099 ))
2100 .header(header::CONTENT_TYPE, "application/json")
2101 .body(axum::body::Body::from(
2102 serde_json::json!({ "priority_threshold": 0 }).to_string(),
2103 ))
2104 .unwrap();
2105 let resp = router.oneshot(req).await.unwrap();
2106 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
2107 }
2108}