Skip to main content

pjson_rs/application/handlers/
command_handlers.rs

1//! Command handlers implementing business use cases
2
3use crate::{
4    application::{ApplicationError, ApplicationResult, commands::*, handlers::CommandHandlerGat},
5    domain::{
6        aggregates::StreamSession,
7        config::limits::{MAX_FRAMES_PER_REQUEST, MAX_SESSION_TIMEOUT_SECONDS},
8        entities::Frame,
9        ports::{
10            DictionaryStore, EventPublisherGat, FrameStoreGat, NoopDictionaryStore,
11            StreamRepositoryGat,
12        },
13        value_objects::{SessionId, StreamId},
14    },
15    infrastructure::adapters::InMemoryFrameStore,
16};
17use std::sync::Arc;
18
19/// Handler for session management commands.
20///
21/// Holds an optional [`DictionaryStore`] (defaulting to [`NoopDictionaryStore`])
22/// so that frame-generating commands can feed accepted frame payloads into the
23/// per-session training corpus. Without this wiring the
24/// `GET /pjs/sessions/{id}/dictionary` endpoint would be unreachable end-to-end.
25///
26/// Also holds a [`FrameStoreGat`] (defaulting to [`InMemoryFrameStore`]) so
27/// frames produced by `GenerateFramesCommand` / `BatchGenerateFramesCommand`
28/// remain queryable through `GET /pjs/sessions/{id}/streams/{id}/frames`.
29pub struct SessionCommandHandler<R, P, F = InMemoryFrameStore>
30where
31    R: StreamRepositoryGat + 'static,
32    P: EventPublisherGat + 'static,
33    F: FrameStoreGat + 'static,
34{
35    repository: Arc<R>,
36    event_publisher: Arc<P>,
37    #[cfg_attr(
38        not(all(feature = "compression", not(target_arch = "wasm32"))),
39        allow(dead_code)
40    )]
41    dictionary_store: Arc<dyn DictionaryStore>,
42    frame_store: Arc<F>,
43}
44
45impl<R, P, F> std::fmt::Debug for SessionCommandHandler<R, P, F>
46where
47    R: StreamRepositoryGat + 'static,
48    P: EventPublisherGat + 'static,
49    F: FrameStoreGat + 'static,
50{
51    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
52        f.debug_struct("SessionCommandHandler")
53            .finish_non_exhaustive()
54    }
55}
56
57impl<R, P> SessionCommandHandler<R, P, InMemoryFrameStore>
58where
59    R: StreamRepositoryGat + 'static,
60    P: EventPublisherGat + 'static,
61{
62    /// Create a handler with the no-op [`DictionaryStore`] and a fresh
63    /// [`InMemoryFrameStore`].
64    ///
65    /// The dictionary endpoint will return `404 Not Found` until the handler is
66    /// constructed with [`SessionCommandHandler::with_stores`] and a concrete
67    /// [`DictionaryStore`] such as
68    /// [`crate::infrastructure::repositories::InMemoryDictionaryStore`].
69    pub fn new(repository: Arc<R>, event_publisher: Arc<P>) -> Self {
70        Self::with_dictionary_store(repository, event_publisher, Arc::new(NoopDictionaryStore))
71    }
72
73    /// Create a handler with a custom [`DictionaryStore`] and a fresh
74    /// [`InMemoryFrameStore`].
75    pub fn with_dictionary_store(
76        repository: Arc<R>,
77        event_publisher: Arc<P>,
78        dictionary_store: Arc<dyn DictionaryStore>,
79    ) -> Self {
80        Self::with_stores(
81            repository,
82            event_publisher,
83            dictionary_store,
84            Arc::new(InMemoryFrameStore::new()),
85        )
86    }
87}
88
89impl<R, P, F> SessionCommandHandler<R, P, F>
90where
91    R: StreamRepositoryGat + 'static,
92    P: EventPublisherGat + 'static,
93    F: FrameStoreGat + 'static,
94{
95    /// Create a handler that feeds accepted frames into both the dictionary
96    /// training corpus and the [`FrameStoreGat`] used by the frames query
97    /// endpoint.
98    pub fn with_stores(
99        repository: Arc<R>,
100        event_publisher: Arc<P>,
101        dictionary_store: Arc<dyn DictionaryStore>,
102        frame_store: Arc<F>,
103    ) -> Self {
104        Self {
105            repository,
106            event_publisher,
107            dictionary_store,
108            frame_store,
109        }
110    }
111
112    /// Feed each accepted frame's serialized payload into the per-session
113    /// training corpus.
114    ///
115    /// Errors are intentionally swallowed: training is best-effort and a
116    /// transient failure must not poison the frame-generation response. The
117    /// `OnceCell` inside `InMemoryDictionaryStore` is not poisoned on error
118    /// either, so the next sample will retry.
119    #[cfg(all(feature = "compression", not(target_arch = "wasm32")))]
120    async fn train_from_frames(&self, session_id: SessionId, frames: &[Frame]) {
121        for frame in frames {
122            if let Ok(bytes) = serde_json::to_vec(frame.payload()) {
123                let _ = self
124                    .dictionary_store
125                    .train_if_ready(session_id, bytes)
126                    .await;
127            }
128        }
129    }
130
131    /// Persist a mutated session and publish its accumulated domain events.
132    ///
133    /// Events are drained from `session` before the persisted clone is taken,
134    /// so the stored copy's `pending_events` buffer is empty and each
135    /// subsequent call only publishes events produced since the last save.
136    /// If `save_session` fails after the drain, those events are discarded
137    /// rather than retried — there is no path back into `pending_events`.
138    async fn save_and_publish(&self, session: &mut StreamSession) -> ApplicationResult<()>
139    where
140        R: StreamRepositoryGat + Send + Sync,
141        P: EventPublisherGat + Send + Sync,
142    {
143        let events: Vec<_> = session.take_events().into_iter().collect();
144        self.repository
145            .save_session(session.clone())
146            .await
147            .map_err(ApplicationError::Domain)?;
148        self.event_publisher
149            .publish_batch(events)
150            .await
151            .map_err(ApplicationError::Domain)?;
152        Ok(())
153    }
154
155    /// Persist generated frames into the [`FrameStoreGat`], grouping by
156    /// `stream_id` so a single batch may span multiple streams.
157    async fn persist_frames_grouped_by_stream(&self, frames: &[Frame]) -> ApplicationResult<()>
158    where
159        F: FrameStoreGat + Send + Sync,
160    {
161        if frames.is_empty() {
162            return Ok(());
163        }
164        let mut buckets: std::collections::HashMap<StreamId, Vec<Frame>> =
165            std::collections::HashMap::new();
166        for frame in frames {
167            buckets
168                .entry(frame.stream_id())
169                .or_default()
170                .push(frame.clone());
171        }
172        for (stream_id, group) in buckets {
173            self.frame_store
174                .append_frames(stream_id, group)
175                .await
176                .map_err(ApplicationError::Domain)?;
177        }
178        Ok(())
179    }
180}
181
182impl<R, P, F> CommandHandlerGat<CreateSessionCommand> for SessionCommandHandler<R, P, F>
183where
184    R: StreamRepositoryGat + Send + Sync,
185    P: EventPublisherGat + Send + Sync,
186    F: FrameStoreGat + Send + Sync,
187{
188    type Response = SessionId;
189
190    type HandleFuture<'a>
191        = impl std::future::Future<Output = ApplicationResult<Self::Response>> + Send + 'a
192    where
193        Self: 'a;
194
195    fn handle(&self, command: CreateSessionCommand) -> Self::HandleFuture<'_> {
196        async move {
197            CommandValidator::validate_create_session(&command)
198                .map_err(|errors| ApplicationError::Validation(errors.join("; ")))?;
199
200            // Create new session
201            let mut session = StreamSession::new(command.config);
202
203            // Set client information
204            if let (Some(client_info), user_agent, ip_address) =
205                (command.client_info, command.user_agent, command.ip_address)
206            {
207                session.set_client_info(client_info, user_agent, ip_address);
208            }
209
210            // Activate session
211            session.activate().map_err(ApplicationError::Domain)?;
212
213            let session_id = session.id();
214
215            self.save_and_publish(&mut session).await?;
216
217            Ok(session_id)
218        }
219    }
220}
221
222impl<R, P, F> CommandHandlerGat<CreateStreamCommand> for SessionCommandHandler<R, P, F>
223where
224    R: StreamRepositoryGat + Send + Sync,
225    P: EventPublisherGat + Send + Sync,
226    F: FrameStoreGat + Send + Sync,
227{
228    type Response = StreamId;
229
230    type HandleFuture<'a>
231        = impl std::future::Future<Output = ApplicationResult<Self::Response>> + Send + 'a
232    where
233        Self: 'a;
234
235    fn handle(&self, command: CreateStreamCommand) -> Self::HandleFuture<'_> {
236        async move {
237            CommandValidator::validate_create_stream(&command)
238                .map_err(|errors| ApplicationError::Validation(errors.join("; ")))?;
239
240            // Atomic per-session read-modify-write (#457): a plain load +
241            // mutate + save cycle can race under a concurrent mutation of
242            // the same session (e.g. a completion) and silently drop one
243            // side's update to SessionStats.
244            let (stream_id, events) = self
245                .repository
246                .create_stream_atomic(
247                    command.session_id.into(),
248                    command.source_data,
249                    command.config,
250                )
251                .await
252                .map_err(|e| match e {
253                    crate::domain::DomainError::SessionNotFound(_) => ApplicationError::NotFound(
254                        format!("Session {} not found", command.session_id),
255                    ),
256                    other => ApplicationError::Domain(other),
257                })?;
258
259            self.event_publisher
260                .publish_batch(events)
261                .await
262                .map_err(ApplicationError::Domain)?;
263
264            Ok(stream_id)
265        }
266    }
267}
268
269impl<R, P, F> CommandHandlerGat<StartStreamCommand> for SessionCommandHandler<R, P, F>
270where
271    R: StreamRepositoryGat + Send + Sync,
272    P: EventPublisherGat + Send + Sync,
273    F: FrameStoreGat + Send + Sync,
274{
275    type Response = ();
276
277    type HandleFuture<'a>
278        = impl std::future::Future<Output = ApplicationResult<Self::Response>> + Send + 'a
279    where
280        Self: 'a;
281
282    fn handle(&self, command: StartStreamCommand) -> Self::HandleFuture<'_> {
283        async move {
284            // Atomic per-session read-modify-write (#457): see CreateStreamCommand.
285            let events = self
286                .repository
287                .start_stream_atomic(command.session_id.into(), command.stream_id.into())
288                .await
289                .map_err(|e| match e {
290                    crate::domain::DomainError::SessionNotFound(_) => ApplicationError::NotFound(
291                        format!("Session {} not found", command.session_id),
292                    ),
293                    other => ApplicationError::Domain(other),
294                })?;
295
296            self.event_publisher
297                .publish_batch(events)
298                .await
299                .map_err(ApplicationError::Domain)?;
300
301            Ok(())
302        }
303    }
304}
305
306impl<R, P, F> CommandHandlerGat<CompleteStreamCommand> for SessionCommandHandler<R, P, F>
307where
308    R: StreamRepositoryGat + Send + Sync,
309    P: EventPublisherGat + Send + Sync,
310    F: FrameStoreGat + Send + Sync,
311{
312    type Response = ();
313
314    type HandleFuture<'a>
315        = impl std::future::Future<Output = ApplicationResult<Self::Response>> + Send + 'a
316    where
317        Self: 'a;
318
319    fn handle(&self, command: CompleteStreamCommand) -> Self::HandleFuture<'_> {
320        async move {
321            // Atomic per-session read-modify-write (#457): a plain load +
322            // mutate + save cycle can race under concurrent completions for
323            // the same session and silently drop one update to SessionStats.
324            let events = self
325                .repository
326                .complete_stream_atomic(command.session_id.into(), command.stream_id.into())
327                .await
328                .map_err(|e| match e {
329                    crate::domain::DomainError::SessionNotFound(_) => ApplicationError::NotFound(
330                        format!("Session {} not found", command.session_id),
331                    ),
332                    other => ApplicationError::Domain(other),
333                })?;
334
335            self.event_publisher
336                .publish_batch(events)
337                .await
338                .map_err(ApplicationError::Domain)?;
339
340            Ok(())
341        }
342    }
343}
344
345impl<R, P, F> CommandHandlerGat<GenerateFramesCommand> for SessionCommandHandler<R, P, F>
346where
347    R: StreamRepositoryGat + Send + Sync,
348    P: EventPublisherGat + Send + Sync,
349    F: FrameStoreGat + Send + Sync,
350{
351    type Response = Vec<Frame>;
352
353    type HandleFuture<'a>
354        = impl std::future::Future<Output = ApplicationResult<Self::Response>> + Send + 'a
355    where
356        Self: 'a;
357
358    fn handle(&self, command: GenerateFramesCommand) -> Self::HandleFuture<'_> {
359        async move {
360            CommandValidator::validate_generate_frames(&command)
361                .map_err(|errors| ApplicationError::Validation(errors.join("; ")))?;
362
363            // Generate frames through the aggregate root so session-level
364            // stats and events stay consistent with the child stream mutation.
365            let priority = command
366                .priority_threshold
367                .try_into()
368                .map_err(ApplicationError::Domain)?;
369
370            // Atomic per-session read-modify-write (#457): see CreateStreamCommand.
371            let (frames, events) = self
372                .repository
373                .create_stream_patch_frames_atomic(
374                    command.session_id.into(),
375                    command.stream_id.into(),
376                    priority,
377                    command.max_frames,
378                )
379                .await
380                .map_err(|e| match e {
381                    crate::domain::DomainError::SessionNotFound(_) => ApplicationError::NotFound(
382                        format!("Session {} not found", command.session_id),
383                    ),
384                    crate::domain::DomainError::StreamNotFound(_) => ApplicationError::NotFound(
385                        format!("Stream {} not found", command.stream_id),
386                    ),
387                    other => ApplicationError::Domain(other),
388                })?;
389
390            // Publish immediately after the atomic call, before any
391            // fallible I/O below: `create_stream_patch_frames_atomic`
392            // already drained these events from the session's buffer inside
393            // its lock, so if a later step (e.g. `append_frames`) fails and
394            // returns early, there is no buffer left to recover them from —
395            // publishing first ensures they are never silently lost.
396            self.event_publisher
397                .publish_batch(events)
398                .await
399                .map_err(ApplicationError::Domain)?;
400
401            // WebSocket frame production runs through a disjoint session model
402            // (`infrastructure/websocket`) and does not increment this counter, so
403            // `pjs_frames_total` reflects HTTP throughput only — see #239.
404            #[cfg(feature = "metrics")]
405            metrics::counter!("pjs_frames_total").increment(frames.len() as u64);
406
407            // Feed accepted frame payloads into the per-session training corpus
408            // so the dictionary endpoint becomes reachable end-to-end.
409            #[cfg(all(feature = "compression", not(target_arch = "wasm32")))]
410            self.train_from_frames(command.session_id.into(), &frames)
411                .await;
412
413            // Persist generated frames so GET /streams/{id}/frames can return them.
414            self.frame_store
415                .append_frames(command.stream_id.into(), frames.clone())
416                .await
417                .map_err(ApplicationError::Domain)?;
418
419            Ok(frames)
420        }
421    }
422}
423
424impl<R, P, F> CommandHandlerGat<BatchGenerateFramesCommand> for SessionCommandHandler<R, P, F>
425where
426    R: StreamRepositoryGat + Send + Sync,
427    P: EventPublisherGat + Send + Sync,
428    F: FrameStoreGat + Send + Sync,
429{
430    type Response = Vec<Frame>;
431
432    type HandleFuture<'a>
433        = impl std::future::Future<Output = ApplicationResult<Self::Response>> + Send + 'a
434    where
435        Self: 'a;
436
437    fn handle(&self, command: BatchGenerateFramesCommand) -> Self::HandleFuture<'_> {
438        async move {
439            // Atomic per-session read-modify-write (#457, #477): see
440            // GenerateFramesCommand for why events publish before any
441            // fallible step below. Trade-off here specifically: if
442            // `persist_frames_grouped_by_stream` fails partway through a
443            // multi-stream batch, `FramesBatched` and session stats are
444            // already committed while `GET .../frames` may return fewer
445            // frames than were reported — accepted as consistent with
446            // GenerateFramesCommand rather than left silently divergent.
447            let priority = command
448                .priority_threshold
449                .try_into()
450                .map_err(ApplicationError::Domain)?;
451
452            let (frames, events) = self
453                .repository
454                .batch_generate_frames_atomic(
455                    command.session_id.into(),
456                    priority,
457                    command.max_frames,
458                )
459                .await
460                .map_err(|e| match e {
461                    crate::domain::DomainError::SessionNotFound(_) => ApplicationError::NotFound(
462                        format!("Session {} not found", command.session_id),
463                    ),
464                    other => ApplicationError::Domain(other),
465                })?;
466
467            self.event_publisher
468                .publish_batch(events)
469                .await
470                .map_err(ApplicationError::Domain)?;
471
472            // WebSocket frame production runs through a disjoint session model
473            // (`infrastructure/websocket`) and does not increment this counter, so
474            // `pjs_frames_total` reflects HTTP throughput only — see #239.
475            #[cfg(feature = "metrics")]
476            metrics::counter!("pjs_frames_total").increment(frames.len() as u64);
477
478            // Feed accepted frame payloads into the per-session training corpus
479            // so the dictionary endpoint becomes reachable end-to-end.
480            #[cfg(all(feature = "compression", not(target_arch = "wasm32")))]
481            self.train_from_frames(command.session_id.into(), &frames)
482                .await;
483
484            // Persist generated frames so GET /streams/{id}/frames can return them.
485            // Frames may span multiple streams in a batch; group by stream_id.
486            self.persist_frames_grouped_by_stream(&frames).await?;
487
488            Ok(frames)
489        }
490    }
491}
492
493impl<R, P, F> CommandHandlerGat<CloseSessionCommand> for SessionCommandHandler<R, P, F>
494where
495    R: StreamRepositoryGat + Send + Sync,
496    P: EventPublisherGat + Send + Sync,
497    F: FrameStoreGat + Send + Sync,
498{
499    type Response = ();
500
501    type HandleFuture<'a>
502        = impl std::future::Future<Output = ApplicationResult<Self::Response>> + Send + 'a
503    where
504        Self: 'a;
505
506    fn handle(&self, command: CloseSessionCommand) -> Self::HandleFuture<'_> {
507        async move {
508            // Atomic per-session read-modify-write (#457): see CreateStreamCommand.
509            let events = self
510                .repository
511                .close_session_atomic(command.session_id.into())
512                .await
513                .map_err(|e| match e {
514                    crate::domain::DomainError::SessionNotFound(_) => ApplicationError::NotFound(
515                        format!("Session {} not found", command.session_id),
516                    ),
517                    other => ApplicationError::Domain(other),
518                })?;
519
520            self.event_publisher
521                .publish_batch(events)
522                .await
523                .map_err(ApplicationError::Domain)?;
524
525            Ok(())
526        }
527    }
528}
529
530/// Validates commands at the application boundary, before they reach the
531/// domain layer.
532///
533/// Invoked as the first statement of [`CommandHandlerGat::handle`] for
534/// [`CreateSessionCommand`], [`CreateStreamCommand`], and
535/// [`GenerateFramesCommand`] on [`SessionCommandHandler`] — ahead of any
536/// session lookup, so a malformed request fails with
537/// [`ApplicationError::Validation`] (HTTP 400) instead of a later domain
538/// error or a misleading 404/500.
539pub struct CommandValidator;
540
541impl CommandValidator {
542    /// Validate CreateSessionCommand
543    pub fn validate_create_session(command: &CreateSessionCommand) -> Result<(), Vec<String>> {
544        let mut errors = Vec::new();
545
546        if command.config.max_concurrent_streams == 0 {
547            errors.push("max_concurrent_streams must be greater than 0".to_string());
548        }
549
550        if command.config.session_timeout_seconds == 0 {
551            errors.push("session_timeout_seconds must be greater than 0".to_string());
552        }
553
554        if command.config.session_timeout_seconds > MAX_SESSION_TIMEOUT_SECONDS {
555            errors.push(format!(
556                "session_timeout_seconds cannot exceed {MAX_SESSION_TIMEOUT_SECONDS}"
557            ));
558        }
559
560        if errors.is_empty() {
561            Ok(())
562        } else {
563            Err(errors)
564        }
565    }
566
567    /// Validate CreateStreamCommand
568    pub fn validate_create_stream(command: &CreateStreamCommand) -> Result<(), Vec<String>> {
569        let mut errors = Vec::new();
570
571        if command.source_data.is_null() {
572            errors.push("source_data cannot be null".to_string());
573        }
574
575        if errors.is_empty() {
576            Ok(())
577        } else {
578            Err(errors)
579        }
580    }
581
582    /// Validate GenerateFramesCommand
583    pub fn validate_generate_frames(command: &GenerateFramesCommand) -> Result<(), Vec<String>> {
584        let mut errors = Vec::new();
585
586        if command.max_frames == 0 {
587            errors.push("max_frames must be greater than 0".to_string());
588        }
589
590        if command.max_frames > MAX_FRAMES_PER_REQUEST {
591            errors.push(format!("max_frames cannot exceed {MAX_FRAMES_PER_REQUEST}"));
592        }
593
594        if errors.is_empty() {
595            Ok(())
596        } else {
597            Err(errors)
598        }
599    }
600}
601
602#[cfg(test)]
603mod tests {
604    use super::*;
605    use crate::domain::{
606        aggregates::stream_session::SessionConfig, events::DomainEvent, ports::EventPublisherGat,
607    };
608    use crate::test_support::MockRepository;
609
610    struct MockEventPublisher;
611
612    impl EventPublisherGat for MockEventPublisher {
613        type PublishFuture<'a>
614            = impl std::future::Future<Output = crate::domain::DomainResult<()>> + Send + 'a
615        where
616            Self: 'a;
617
618        type PublishBatchFuture<'a>
619            = impl std::future::Future<Output = crate::domain::DomainResult<()>> + Send + 'a
620        where
621            Self: 'a;
622
623        fn publish(&self, _event: DomainEvent) -> Self::PublishFuture<'_> {
624            async move { Ok(()) }
625        }
626
627        fn publish_batch(&self, _events: Vec<DomainEvent>) -> Self::PublishBatchFuture<'_> {
628            async move { Ok(()) }
629        }
630    }
631
632    /// Event publisher that records each `publish_batch` call as its own
633    /// batch, so a test can inspect exactly which events each command's
634    /// `save_and_publish` call emitted.
635    struct RecordingEventPublisher {
636        batches: parking_lot::Mutex<Vec<Vec<DomainEvent>>>,
637    }
638
639    impl RecordingEventPublisher {
640        fn new() -> Self {
641            Self {
642                batches: parking_lot::Mutex::new(Vec::new()),
643            }
644        }
645
646        fn batches(&self) -> Vec<Vec<DomainEvent>> {
647            self.batches.lock().clone()
648        }
649    }
650
651    impl EventPublisherGat for RecordingEventPublisher {
652        type PublishFuture<'a>
653            = impl std::future::Future<Output = crate::domain::DomainResult<()>> + Send + 'a
654        where
655            Self: 'a;
656
657        type PublishBatchFuture<'a>
658            = impl std::future::Future<Output = crate::domain::DomainResult<()>> + Send + 'a
659        where
660            Self: 'a;
661
662        fn publish(&self, event: DomainEvent) -> Self::PublishFuture<'_> {
663            async move {
664                self.batches.lock().push(vec![event]);
665                Ok(())
666            }
667        }
668
669        fn publish_batch(&self, events: Vec<DomainEvent>) -> Self::PublishBatchFuture<'_> {
670            async move {
671                self.batches.lock().push(events);
672                Ok(())
673            }
674        }
675    }
676
677    /// Event publisher that records every published event, for asserting on
678    /// what actually got published rather than merely whether the handler
679    /// returned `Ok`.
680    struct TrackingEventPublisher {
681        published: parking_lot::Mutex<Vec<DomainEvent>>,
682    }
683
684    impl TrackingEventPublisher {
685        fn new() -> Self {
686            Self {
687                published: parking_lot::Mutex::new(Vec::new()),
688            }
689        }
690
691        fn published_events(&self) -> Vec<DomainEvent> {
692            self.published.lock().clone()
693        }
694    }
695
696    impl EventPublisherGat for TrackingEventPublisher {
697        type PublishFuture<'a>
698            = impl std::future::Future<Output = crate::domain::DomainResult<()>> + Send + 'a
699        where
700            Self: 'a;
701
702        type PublishBatchFuture<'a>
703            = impl std::future::Future<Output = crate::domain::DomainResult<()>> + Send + 'a
704        where
705            Self: 'a;
706
707        fn publish(&self, event: DomainEvent) -> Self::PublishFuture<'_> {
708            async move {
709                self.published.lock().push(event);
710                Ok(())
711            }
712        }
713
714        fn publish_batch(&self, events: Vec<DomainEvent>) -> Self::PublishBatchFuture<'_> {
715            async move {
716                self.published.lock().extend(events);
717                Ok(())
718            }
719        }
720    }
721
722    /// Frame store whose `append_frames` always fails, for testing that a
723    /// downstream persistence failure doesn't discard events already
724    /// drained from the session's atomic mutation.
725    struct FailingFrameStore;
726
727    impl crate::domain::ports::FrameStoreGat for FailingFrameStore {
728        type AppendFramesFuture<'a>
729            = impl std::future::Future<Output = crate::domain::DomainResult<()>> + Send + 'a
730        where
731            Self: 'a;
732
733        type GetFramesFuture<'a>
734            = impl std::future::Future<
735                Output = crate::domain::DomainResult<crate::domain::ports::FrameStorePage>,
736            > + Send
737            + 'a
738        where
739            Self: 'a;
740
741        type DeleteFramesForStreamFuture<'a>
742            = impl std::future::Future<Output = crate::domain::DomainResult<()>> + Send + 'a
743        where
744            Self: 'a;
745
746        fn append_frames(
747            &self,
748            _stream_id: StreamId,
749            _frames: Vec<Frame>,
750        ) -> Self::AppendFramesFuture<'_> {
751            async move {
752                Err(crate::domain::DomainError::InvalidInput(
753                    "simulated frame store failure".to_string(),
754                ))
755            }
756        }
757
758        fn get_frames(
759            &self,
760            _stream_id: StreamId,
761            _since_sequence: Option<u64>,
762            _priority_filter: Option<crate::domain::value_objects::Priority>,
763            _limit: Option<usize>,
764        ) -> Self::GetFramesFuture<'_> {
765            async move {
766                Ok(crate::domain::ports::FrameStorePage {
767                    frames: Vec::new(),
768                    total_matching: 0,
769                })
770            }
771        }
772
773        fn delete_frames_for_stream(
774            &self,
775            _stream_id: StreamId,
776        ) -> Self::DeleteFramesForStreamFuture<'_> {
777            async move { Ok(()) }
778        }
779    }
780    #[tokio::test]
781    async fn test_create_session_command() {
782        let repository = Arc::new(MockRepository::new());
783        let event_publisher = Arc::new(MockEventPublisher);
784        let handler = SessionCommandHandler::new(repository.clone(), event_publisher);
785
786        let command = CreateSessionCommand {
787            config: SessionConfig::default(),
788            client_info: Some("test-client".to_string()),
789            user_agent: None,
790            ip_address: None,
791        };
792
793        let result = handler.handle(command).await;
794        assert!(result.is_ok());
795
796        let session_id = result.unwrap();
797
798        // Verify session was saved
799        let saved_session = repository.find_session(session_id).await.unwrap();
800        assert!(saved_session.is_some());
801    }
802
803    #[tokio::test]
804    async fn test_session_command_handler_creation() {
805        let repository = Arc::new(MockRepository::new());
806        let event_publisher = Arc::new(MockEventPublisher);
807        let handler = SessionCommandHandler::new(repository.clone(), event_publisher.clone());
808
809        assert!(std::ptr::eq(
810            handler.repository.as_ref(),
811            repository.as_ref()
812        ));
813        assert!(std::ptr::eq(
814            handler.event_publisher.as_ref(),
815            event_publisher.as_ref()
816        ));
817    }
818
819    #[tokio::test]
820    async fn test_create_session_with_full_client_info() {
821        let repository = Arc::new(MockRepository::new());
822        let event_publisher = Arc::new(MockEventPublisher);
823        let handler = SessionCommandHandler::new(repository.clone(), event_publisher);
824
825        let command = CreateSessionCommand {
826            config: SessionConfig::default(),
827            client_info: Some("test-client".to_string()),
828            user_agent: Some("Mozilla/5.0".to_string()),
829            ip_address: Some("192.168.1.1".to_string()),
830        };
831
832        let result = handler.handle(command).await;
833        assert!(result.is_ok());
834
835        let session_id = result.unwrap();
836        let saved_session = repository.find_session(session_id).await.unwrap();
837        assert!(saved_session.is_some());
838    }
839
840    #[tokio::test]
841    async fn test_create_session_without_client_info() {
842        let repository = Arc::new(MockRepository::new());
843        let event_publisher = Arc::new(MockEventPublisher);
844        let handler = SessionCommandHandler::new(repository, event_publisher);
845
846        let command = CreateSessionCommand {
847            config: SessionConfig::default(),
848            client_info: None,
849            user_agent: None,
850            ip_address: None,
851        };
852
853        let result = handler.handle(command).await;
854        assert!(result.is_ok());
855    }
856
857    #[tokio::test]
858    async fn test_create_stream_command() {
859        let repository = Arc::new(MockRepository::new());
860        let event_publisher = Arc::new(MockEventPublisher);
861        let handler = SessionCommandHandler::new(repository.clone(), event_publisher);
862
863        // First create a session
864        let create_session_cmd = CreateSessionCommand {
865            config: SessionConfig::default(),
866            client_info: None,
867            user_agent: None,
868            ip_address: None,
869        };
870
871        let session_id = handler.handle(create_session_cmd).await.unwrap();
872
873        // Then create a stream
874        let create_stream_cmd = CreateStreamCommand {
875            session_id: session_id.into(),
876            source_data: serde_json::json!({"test": "data"}).into(),
877            config: None,
878        };
879
880        let result = handler.handle(create_stream_cmd).await;
881        assert!(result.is_ok());
882
883        let stream_id = result.unwrap();
884        assert_ne!(stream_id, StreamId::new()); // Should be a valid stream ID
885    }
886
887    #[tokio::test]
888    async fn test_create_stream_with_config() {
889        let repository = Arc::new(MockRepository::new());
890        let event_publisher = Arc::new(MockEventPublisher);
891        let handler = SessionCommandHandler::new(repository.clone(), event_publisher);
892
893        // Create session first
894        let session_id = handler
895            .handle(CreateSessionCommand {
896                config: SessionConfig::default(),
897                client_info: None,
898                user_agent: None,
899                ip_address: None,
900            })
901            .await
902            .unwrap();
903
904        // Create stream with config
905        let stream_config = crate::domain::entities::stream::StreamConfig::default();
906        let create_stream_cmd = CreateStreamCommand {
907            session_id: session_id.into(),
908            source_data: serde_json::json!({"test": "data"}).into(),
909            config: Some(stream_config),
910        };
911
912        let result = handler.handle(create_stream_cmd).await;
913        assert!(result.is_ok());
914    }
915
916    #[tokio::test]
917    async fn test_create_stream_session_not_found() {
918        let repository = Arc::new(MockRepository::new());
919        let event_publisher = Arc::new(MockEventPublisher);
920        let handler = SessionCommandHandler::new(repository, event_publisher);
921
922        let non_existent_session_id = SessionId::new();
923        let create_stream_cmd = CreateStreamCommand {
924            session_id: non_existent_session_id.into(),
925            source_data: serde_json::json!({"test": "data"}).into(),
926            config: None,
927        };
928
929        let result = handler.handle(create_stream_cmd).await;
930        assert!(result.is_err());
931        assert!(matches!(
932            result.err().unwrap(),
933            ApplicationError::NotFound(_)
934        ));
935    }
936
937    #[tokio::test]
938    async fn test_start_stream_command() {
939        let repository = Arc::new(MockRepository::new());
940        let event_publisher = Arc::new(MockEventPublisher);
941        let handler = SessionCommandHandler::new(repository.clone(), event_publisher);
942
943        // Create session and stream first
944        let session_id = handler
945            .handle(CreateSessionCommand {
946                config: SessionConfig::default(),
947                client_info: None,
948                user_agent: None,
949                ip_address: None,
950            })
951            .await
952            .unwrap();
953
954        let stream_id = handler
955            .handle(CreateStreamCommand {
956                session_id: session_id.into(),
957                source_data: serde_json::json!({"test": "data"}).into(),
958                config: None,
959            })
960            .await
961            .unwrap();
962
963        // Start the stream
964        let start_stream_cmd = StartStreamCommand {
965            session_id: session_id.into(),
966            stream_id: stream_id.into(),
967        };
968
969        let result = handler.handle(start_stream_cmd).await;
970        assert!(result.is_ok());
971    }
972
973    #[tokio::test]
974    async fn test_start_stream_session_not_found() {
975        let repository = Arc::new(MockRepository::new());
976        let event_publisher = Arc::new(MockEventPublisher);
977        let handler = SessionCommandHandler::new(repository, event_publisher);
978
979        let start_stream_cmd = StartStreamCommand {
980            session_id: SessionId::new().into(),
981            stream_id: StreamId::new().into(),
982        };
983
984        let result = handler.handle(start_stream_cmd).await;
985        assert!(result.is_err());
986        assert!(matches!(
987            result.err().unwrap(),
988            ApplicationError::NotFound(_)
989        ));
990    }
991
992    #[tokio::test]
993    async fn test_complete_stream_command() {
994        let repository = Arc::new(MockRepository::new());
995        let event_publisher = Arc::new(MockEventPublisher);
996        let handler = SessionCommandHandler::new(repository.clone(), event_publisher);
997
998        // Create session, stream, and start it
999        let session_id = handler
1000            .handle(CreateSessionCommand {
1001                config: SessionConfig::default(),
1002                client_info: None,
1003                user_agent: None,
1004                ip_address: None,
1005            })
1006            .await
1007            .unwrap();
1008
1009        let stream_id = handler
1010            .handle(CreateStreamCommand {
1011                session_id: session_id.into(),
1012                source_data: serde_json::json!({"test": "data"}).into(),
1013                config: None,
1014            })
1015            .await
1016            .unwrap();
1017
1018        handler
1019            .handle(StartStreamCommand {
1020                session_id: session_id.into(),
1021                stream_id: stream_id.into(),
1022            })
1023            .await
1024            .unwrap();
1025
1026        // Complete the stream
1027        let complete_stream_cmd = CompleteStreamCommand {
1028            session_id: session_id.into(),
1029            stream_id: stream_id.into(),
1030            checksum: Some("abc123".to_string()),
1031        };
1032
1033        let result = handler.handle(complete_stream_cmd).await;
1034        assert!(result.is_ok());
1035    }
1036
1037    #[tokio::test]
1038    async fn test_complete_stream_without_checksum() {
1039        let repository = Arc::new(MockRepository::new());
1040        let event_publisher = Arc::new(MockEventPublisher);
1041        let handler = SessionCommandHandler::new(repository.clone(), event_publisher);
1042
1043        // Create and start stream
1044        let session_id = handler
1045            .handle(CreateSessionCommand {
1046                config: SessionConfig::default(),
1047                client_info: None,
1048                user_agent: None,
1049                ip_address: None,
1050            })
1051            .await
1052            .unwrap();
1053
1054        let stream_id = handler
1055            .handle(CreateStreamCommand {
1056                session_id: session_id.into(),
1057                source_data: serde_json::json!({"test": "data"}).into(),
1058                config: None,
1059            })
1060            .await
1061            .unwrap();
1062
1063        handler
1064            .handle(StartStreamCommand {
1065                session_id: session_id.into(),
1066                stream_id: stream_id.into(),
1067            })
1068            .await
1069            .unwrap();
1070
1071        // Complete without checksum
1072        let complete_stream_cmd = CompleteStreamCommand {
1073            session_id: session_id.into(),
1074            stream_id: stream_id.into(),
1075            checksum: None,
1076        };
1077
1078        let result = handler.handle(complete_stream_cmd).await;
1079        assert!(result.is_ok());
1080    }
1081
1082    #[tokio::test]
1083    async fn test_close_session_command() {
1084        let repository = Arc::new(MockRepository::new());
1085        let event_publisher = Arc::new(MockEventPublisher);
1086        let handler = SessionCommandHandler::new(repository.clone(), event_publisher);
1087
1088        // Create session first
1089        let session_id = handler
1090            .handle(CreateSessionCommand {
1091                config: SessionConfig::default(),
1092                client_info: None,
1093                user_agent: None,
1094                ip_address: None,
1095            })
1096            .await
1097            .unwrap();
1098
1099        // Close the session
1100        let close_session_cmd = CloseSessionCommand {
1101            session_id: session_id.into(),
1102        };
1103
1104        let result = handler.handle(close_session_cmd).await;
1105        assert!(result.is_ok());
1106    }
1107
1108    #[tokio::test]
1109    async fn test_close_session_not_found() {
1110        let repository = Arc::new(MockRepository::new());
1111        let event_publisher = Arc::new(MockEventPublisher);
1112        let handler = SessionCommandHandler::new(repository, event_publisher);
1113
1114        let close_session_cmd = CloseSessionCommand {
1115            session_id: SessionId::new().into(),
1116        };
1117
1118        let result = handler.handle(close_session_cmd).await;
1119        assert!(result.is_err());
1120        assert!(matches!(
1121            result.err().unwrap(),
1122            ApplicationError::NotFound(_)
1123        ));
1124    }
1125
1126    #[cfg(all(feature = "compression", not(target_arch = "wasm32")))]
1127    mod dictionary_wiring {
1128        //! Regression tests for issue #224 — frame-ingest must feed the per-session
1129        //! training corpus so that `GET /pjs/sessions/{id}/dictionary` becomes
1130        //! reachable end-to-end.
1131        use super::*;
1132        use crate::{
1133            compression::zstd::N_TRAIN,
1134            domain::{
1135                entities::Frame,
1136                ports::{DictionaryFuture, DictionaryStore},
1137            },
1138            infrastructure::repositories::InMemoryDictionaryStore,
1139            security::CompressionBombDetector,
1140        };
1141        use pjson_rs_domain::value_objects::{JsonData, StreamId};
1142        use std::sync::atomic::{AtomicUsize, Ordering};
1143
1144        /// Counts every `train_if_ready` invocation so a test can verify the
1145        /// command handler reaches the dictionary store at all.
1146        struct CountingDictionaryStore {
1147            inner: InMemoryDictionaryStore,
1148            calls: AtomicUsize,
1149        }
1150
1151        impl CountingDictionaryStore {
1152            fn new() -> Self {
1153                Self {
1154                    inner: InMemoryDictionaryStore::new(
1155                        Arc::new(CompressionBombDetector::default()),
1156                        64 * 1024,
1157                    ),
1158                    calls: AtomicUsize::new(0),
1159                }
1160            }
1161
1162            fn call_count(&self) -> usize {
1163                self.calls.load(Ordering::SeqCst)
1164            }
1165        }
1166
1167        impl DictionaryStore for CountingDictionaryStore {
1168            fn get_dictionary<'a>(
1169                &'a self,
1170                session_id: SessionId,
1171            ) -> DictionaryFuture<'a, Option<Arc<crate::compression::zstd::ZstdDictionary>>>
1172            {
1173                self.inner.get_dictionary(session_id)
1174            }
1175
1176            fn train_if_ready<'a>(
1177                &'a self,
1178                session_id: SessionId,
1179                sample: Vec<u8>,
1180            ) -> DictionaryFuture<'a, ()> {
1181                self.calls.fetch_add(1, Ordering::SeqCst);
1182                self.inner.train_if_ready(session_id, sample)
1183            }
1184        }
1185
1186        fn make_patch_frame(stream_id: StreamId, sequence: u64, n: usize) -> Frame {
1187            let patch = crate::domain::entities::frame::FramePatch::set(
1188                pjson_rs_domain::value_objects::JsonPath::new(format!("$.items[{n}]")).unwrap(),
1189                JsonData::Integer(n as i64),
1190            );
1191            Frame::patch(
1192                stream_id,
1193                sequence,
1194                pjson_rs_domain::value_objects::Priority::HIGH,
1195                vec![patch],
1196            )
1197            .unwrap()
1198        }
1199
1200        #[tokio::test]
1201        async fn test_train_from_frames_records_each_payload() {
1202            let store = Arc::new(CountingDictionaryStore::new());
1203            let handler = SessionCommandHandler::with_dictionary_store(
1204                Arc::new(MockRepository::new()),
1205                Arc::new(MockEventPublisher),
1206                store.clone(),
1207            );
1208
1209            let session_id = SessionId::new();
1210            let stream_id = StreamId::new();
1211            let frames: Vec<Frame> = (0..5)
1212                .map(|i| make_patch_frame(stream_id, i as u64, i))
1213                .collect();
1214
1215            handler.train_from_frames(session_id, &frames).await;
1216
1217            assert_eq!(
1218                store.call_count(),
1219                5,
1220                "every accepted frame must feed train_if_ready"
1221            );
1222        }
1223
1224        #[tokio::test]
1225        async fn test_train_from_frames_fires_dictionary_after_threshold() {
1226            let store = Arc::new(InMemoryDictionaryStore::new(
1227                Arc::new(CompressionBombDetector::default()),
1228                64 * 1024,
1229            ));
1230            let handler = SessionCommandHandler::with_dictionary_store(
1231                Arc::new(MockRepository::new()),
1232                Arc::new(MockEventPublisher),
1233                store.clone(),
1234            );
1235
1236            let session_id = SessionId::new();
1237            let stream_id = StreamId::new();
1238            let frames: Vec<Frame> = (0..N_TRAIN)
1239                .map(|i| make_patch_frame(stream_id, i as u64, i))
1240                .collect();
1241
1242            handler.train_from_frames(session_id, &frames).await;
1243
1244            let dict = store.get_dictionary(session_id).await.unwrap();
1245            assert!(
1246                dict.is_some(),
1247                "dictionary must be trained once N_TRAIN frame payloads have been ingested"
1248            );
1249        }
1250    }
1251
1252    #[tokio::test]
1253    async fn test_generate_frames_persists_into_frame_store() {
1254        use crate::domain::ports::FrameStoreGat;
1255        use crate::infrastructure::adapters::InMemoryFrameStore;
1256
1257        let repository = Arc::new(MockRepository::new());
1258        let event_publisher = Arc::new(MockEventPublisher);
1259        let frame_store = Arc::new(InMemoryFrameStore::new());
1260
1261        let handler = SessionCommandHandler::with_stores(
1262            repository.clone(),
1263            event_publisher,
1264            Arc::new(crate::domain::ports::NoopDictionaryStore),
1265            frame_store.clone(),
1266        );
1267
1268        // Bring up an active session with one stream.
1269        let session_id = handler
1270            .handle(CreateSessionCommand {
1271                config: SessionConfig::default(),
1272                client_info: None,
1273                user_agent: None,
1274                ip_address: None,
1275            })
1276            .await
1277            .unwrap();
1278
1279        let stream_id = handler
1280            .handle(CreateStreamCommand {
1281                session_id: session_id.into(),
1282                source_data: serde_json::json!({"items": [1, 2, 3, 4]}).into(),
1283                config: None,
1284            })
1285            .await
1286            .unwrap();
1287
1288        // Frames can only be produced from a streaming stream.
1289        handler
1290            .handle(StartStreamCommand {
1291                session_id: session_id.into(),
1292                stream_id: stream_id.into(),
1293            })
1294            .await
1295            .unwrap();
1296
1297        // GenerateFrames must (a) return the frames and (b) leave them in the
1298        // frame store so the GET endpoint can find them.
1299        let frames = handler
1300            .handle(GenerateFramesCommand {
1301                session_id: session_id.into(),
1302                stream_id: stream_id.into(),
1303                priority_threshold: crate::application::dto::PriorityDto::new(1).unwrap(),
1304                max_frames: 8,
1305            })
1306            .await
1307            .unwrap();
1308
1309        assert!(
1310            !frames.is_empty(),
1311            "command must produce at least one frame"
1312        );
1313
1314        let page = frame_store
1315            .get_frames(stream_id, None, None, None)
1316            .await
1317            .unwrap();
1318        assert_eq!(
1319            page.frames.len(),
1320            frames.len(),
1321            "every frame returned by the command must be persisted",
1322        );
1323        assert_eq!(page.total_matching, frames.len());
1324    }
1325
1326    #[tokio::test]
1327    async fn test_generate_frames_publishes_events_before_frame_store_failure() {
1328        // Regression test: create_stream_patch_frames_atomic drains the
1329        // session's event buffer inside its lock, so once it returns there
1330        // is no buffer left to recover those events from. The handler must
1331        // publish them before any later fallible step (append_frames) can
1332        // discard them on error.
1333        let repository = Arc::new(MockRepository::new());
1334        let event_publisher = Arc::new(TrackingEventPublisher::new());
1335        let frame_store = Arc::new(FailingFrameStore);
1336
1337        let handler = SessionCommandHandler::with_stores(
1338            repository.clone(),
1339            event_publisher.clone(),
1340            Arc::new(crate::domain::ports::NoopDictionaryStore),
1341            frame_store,
1342        );
1343
1344        let session_id = handler
1345            .handle(CreateSessionCommand {
1346                config: SessionConfig::default(),
1347                client_info: None,
1348                user_agent: None,
1349                ip_address: None,
1350            })
1351            .await
1352            .unwrap();
1353
1354        let stream_id = handler
1355            .handle(CreateStreamCommand {
1356                session_id: session_id.into(),
1357                source_data: serde_json::json!({"items": [1, 2, 3, 4]}).into(),
1358                config: None,
1359            })
1360            .await
1361            .unwrap();
1362
1363        handler
1364            .handle(StartStreamCommand {
1365                session_id: session_id.into(),
1366                stream_id: stream_id.into(),
1367            })
1368            .await
1369            .unwrap();
1370
1371        // Every event published above (SessionActivated, StreamCreated,
1372        // StreamStarted) is irrelevant to this assertion; only what happens
1373        // from here matters.
1374        let events_before = event_publisher.published_events().len();
1375
1376        let result = handler
1377            .handle(GenerateFramesCommand {
1378                session_id: session_id.into(),
1379                stream_id: stream_id.into(),
1380                priority_threshold: crate::application::dto::PriorityDto::new(1).unwrap(),
1381                max_frames: 8,
1382            })
1383            .await;
1384
1385        assert!(
1386            result.is_err(),
1387            "FailingFrameStore must cause the command to fail"
1388        );
1389
1390        let events_after = event_publisher.published_events().len();
1391        assert!(
1392            events_after > events_before,
1393            "FramesBatched must be published even though append_frames failed \
1394             (was {events_before}, now {events_after})"
1395        );
1396    }
1397
1398    #[tokio::test]
1399    async fn test_create_session_rejects_zero_max_concurrent_streams() {
1400        let repository = Arc::new(MockRepository::new());
1401        let event_publisher = Arc::new(MockEventPublisher);
1402        let handler = SessionCommandHandler::new(repository, event_publisher);
1403
1404        let config = SessionConfig {
1405            max_concurrent_streams: 0,
1406            ..Default::default()
1407        };
1408
1409        let result = handler
1410            .handle(CreateSessionCommand {
1411                config,
1412                client_info: None,
1413                user_agent: None,
1414                ip_address: None,
1415            })
1416            .await;
1417
1418        assert!(matches!(result, Err(ApplicationError::Validation(_))));
1419    }
1420
1421    #[tokio::test]
1422    async fn test_create_session_rejects_zero_session_timeout() {
1423        let repository = Arc::new(MockRepository::new());
1424        let event_publisher = Arc::new(MockEventPublisher);
1425        let handler = SessionCommandHandler::new(repository, event_publisher);
1426
1427        let config = SessionConfig {
1428            session_timeout_seconds: 0,
1429            ..Default::default()
1430        };
1431
1432        let result = handler
1433            .handle(CreateSessionCommand {
1434                config,
1435                client_info: None,
1436                user_agent: None,
1437                ip_address: None,
1438            })
1439            .await;
1440
1441        assert!(matches!(result, Err(ApplicationError::Validation(_))));
1442    }
1443
1444    #[tokio::test]
1445    async fn test_create_stream_rejects_null_source_data_before_session_lookup() {
1446        let repository = Arc::new(MockRepository::new());
1447        let event_publisher = Arc::new(MockEventPublisher);
1448        let handler = SessionCommandHandler::new(repository, event_publisher);
1449
1450        // A nonexistent session id proves validation runs before load_session
1451        // — otherwise this would fail with `ApplicationError::NotFound`.
1452        let result = handler
1453            .handle(CreateStreamCommand {
1454                session_id: SessionId::new().into(),
1455                source_data: serde_json::Value::Null.into(),
1456                config: None,
1457            })
1458            .await;
1459
1460        assert!(matches!(result, Err(ApplicationError::Validation(_))));
1461    }
1462
1463    #[tokio::test]
1464    async fn test_generate_frames_rejects_zero_max_frames() {
1465        let repository = Arc::new(MockRepository::new());
1466        let event_publisher = Arc::new(MockEventPublisher);
1467        let handler = SessionCommandHandler::new(repository, event_publisher);
1468
1469        let result = handler
1470            .handle(GenerateFramesCommand {
1471                session_id: SessionId::new().into(),
1472                stream_id: StreamId::new().into(),
1473                priority_threshold: crate::application::dto::PriorityDto::new(1).unwrap(),
1474                max_frames: 0,
1475            })
1476            .await;
1477
1478        assert!(matches!(result, Err(ApplicationError::Validation(_))));
1479    }
1480
1481    #[tokio::test]
1482    async fn test_generate_frames_rejects_max_frames_over_limit() {
1483        let repository = Arc::new(MockRepository::new());
1484        let event_publisher = Arc::new(MockEventPublisher);
1485        let handler = SessionCommandHandler::new(repository, event_publisher);
1486
1487        let result = handler
1488            .handle(GenerateFramesCommand {
1489                session_id: SessionId::new().into(),
1490                stream_id: StreamId::new().into(),
1491                priority_threshold: crate::application::dto::PriorityDto::new(1).unwrap(),
1492                max_frames: MAX_FRAMES_PER_REQUEST + 1,
1493            })
1494            .await;
1495
1496        assert!(matches!(result, Err(ApplicationError::Validation(_))));
1497    }
1498
1499    #[tokio::test]
1500    async fn test_batch_generate_frames_persists_into_frame_store() {
1501        use crate::domain::ports::FrameStoreGat;
1502        use crate::infrastructure::adapters::InMemoryFrameStore;
1503
1504        let repository = Arc::new(MockRepository::new());
1505        let event_publisher = Arc::new(MockEventPublisher);
1506        let frame_store = Arc::new(InMemoryFrameStore::new());
1507
1508        let handler = SessionCommandHandler::with_stores(
1509            repository.clone(),
1510            event_publisher,
1511            Arc::new(crate::domain::ports::NoopDictionaryStore),
1512            frame_store.clone(),
1513        );
1514
1515        let session_id = handler
1516            .handle(CreateSessionCommand {
1517                config: SessionConfig::default(),
1518                client_info: None,
1519                user_agent: None,
1520                ip_address: None,
1521            })
1522            .await
1523            .unwrap();
1524
1525        let stream_id = handler
1526            .handle(CreateStreamCommand {
1527                session_id: session_id.into(),
1528                source_data: serde_json::json!({"items": [1, 2, 3, 4]}).into(),
1529                config: None,
1530            })
1531            .await
1532            .unwrap();
1533
1534        handler
1535            .handle(StartStreamCommand {
1536                session_id: session_id.into(),
1537                stream_id: stream_id.into(),
1538            })
1539            .await
1540            .unwrap();
1541
1542        // BatchGenerateFrames must (a) route through the atomic repository
1543        // call (#477) and (b) leave the frames in the frame store so the GET
1544        // endpoint can find them.
1545        let frames = handler
1546            .handle(BatchGenerateFramesCommand {
1547                session_id: session_id.into(),
1548                priority_threshold: crate::application::dto::PriorityDto::new(1).unwrap(),
1549                max_frames: 8,
1550            })
1551            .await
1552            .unwrap();
1553
1554        assert!(
1555            !frames.is_empty(),
1556            "command must produce at least one frame"
1557        );
1558
1559        let page = frame_store
1560            .get_frames(stream_id, None, None, None)
1561            .await
1562            .unwrap();
1563        assert_eq!(
1564            page.frames.len(),
1565            frames.len(),
1566            "every frame returned by the command must be persisted",
1567        );
1568    }
1569
1570    #[tokio::test]
1571    async fn test_batch_generate_frames_session_not_found() {
1572        let repository = Arc::new(MockRepository::new());
1573        let event_publisher = Arc::new(MockEventPublisher);
1574        let handler = SessionCommandHandler::new(repository, event_publisher);
1575
1576        let result = handler
1577            .handle(BatchGenerateFramesCommand {
1578                session_id: SessionId::new().into(),
1579                priority_threshold: crate::application::dto::PriorityDto::new(1).unwrap(),
1580                max_frames: 8,
1581            })
1582            .await;
1583
1584        assert!(matches!(result, Err(ApplicationError::NotFound(_))));
1585    }
1586
1587    #[tokio::test]
1588    async fn test_batch_generate_frames_no_streaming_streams_returns_empty() {
1589        let repository = Arc::new(MockRepository::new());
1590        let event_publisher = Arc::new(MockEventPublisher);
1591        let handler = SessionCommandHandler::new(repository, event_publisher);
1592
1593        let session_id = handler
1594            .handle(CreateSessionCommand {
1595                config: SessionConfig::default(),
1596                client_info: None,
1597                user_agent: None,
1598                ip_address: None,
1599            })
1600            .await
1601            .unwrap();
1602
1603        let stream_id = handler
1604            .handle(CreateStreamCommand {
1605                session_id: session_id.into(),
1606                source_data: serde_json::json!({"a": 1}).into(),
1607                config: None,
1608            })
1609            .await
1610            .unwrap();
1611        handler
1612            .handle(StartStreamCommand {
1613                session_id: session_id.into(),
1614                stream_id: stream_id.into(),
1615            })
1616            .await
1617            .unwrap();
1618        handler
1619            .handle(CompleteStreamCommand {
1620                session_id: session_id.into(),
1621                stream_id: stream_id.into(),
1622                checksum: None,
1623            })
1624            .await
1625            .unwrap();
1626
1627        // No Streaming streams left — the command must succeed with an empty
1628        // result, not error.
1629        let frames = handler
1630            .handle(BatchGenerateFramesCommand {
1631                session_id: session_id.into(),
1632                priority_threshold: crate::application::dto::PriorityDto::new(1).unwrap(),
1633                max_frames: 8,
1634            })
1635            .await
1636            .unwrap();
1637
1638        assert!(frames.is_empty());
1639    }
1640
1641    /// Regression test for #498: `BatchGenerateFramesCommand::priority_threshold`
1642    /// must actually filter which patches are emitted, not be silently
1643    /// replaced by a hardcoded `Priority::BACKGROUND` inside
1644    /// `batch_generate_frames_atomic`. `"logs"` is heuristically classified
1645    /// `BACKGROUND` (see `services::priority`), so a `HIGH` threshold must
1646    /// drop it entirely.
1647    #[tokio::test]
1648    async fn test_batch_generate_frames_priority_threshold_filters_low_priority_patches() {
1649        let repository = Arc::new(MockRepository::new());
1650        let event_publisher = Arc::new(MockEventPublisher);
1651        let handler = SessionCommandHandler::new(repository, event_publisher);
1652
1653        let session_id = handler
1654            .handle(CreateSessionCommand {
1655                config: SessionConfig::default(),
1656                client_info: None,
1657                user_agent: None,
1658                ip_address: None,
1659            })
1660            .await
1661            .unwrap();
1662
1663        let stream_id = handler
1664            .handle(CreateStreamCommand {
1665                session_id: session_id.into(),
1666                source_data: serde_json::json!({"logs": "background noise"}).into(),
1667                config: None,
1668            })
1669            .await
1670            .unwrap();
1671        handler
1672            .handle(StartStreamCommand {
1673                session_id: session_id.into(),
1674                stream_id: stream_id.into(),
1675            })
1676            .await
1677            .unwrap();
1678
1679        let frames = handler
1680            .handle(BatchGenerateFramesCommand {
1681                session_id: session_id.into(),
1682                priority_threshold: crate::application::dto::PriorityDto::new(
1683                    crate::domain::value_objects::Priority::HIGH.value(),
1684                )
1685                .unwrap(),
1686                max_frames: 8,
1687            })
1688            .await
1689            .unwrap();
1690
1691        assert!(
1692            frames.is_empty(),
1693            "a HIGH priority_threshold must filter out the BACKGROUND-priority \
1694             `logs` patch instead of falling back to Priority::BACKGROUND"
1695        );
1696    }
1697
1698    #[tokio::test]
1699    async fn test_batch_generate_frames_max_frames_zero_returns_empty() {
1700        let repository = Arc::new(MockRepository::new());
1701        let event_publisher = Arc::new(MockEventPublisher);
1702        let handler = SessionCommandHandler::new(repository, event_publisher);
1703
1704        let session_id = handler
1705            .handle(CreateSessionCommand {
1706                config: SessionConfig::default(),
1707                client_info: None,
1708                user_agent: None,
1709                ip_address: None,
1710            })
1711            .await
1712            .unwrap();
1713
1714        let stream_id = handler
1715            .handle(CreateStreamCommand {
1716                session_id: session_id.into(),
1717                source_data: serde_json::json!({"a": 1}).into(),
1718                config: None,
1719            })
1720            .await
1721            .unwrap();
1722        handler
1723            .handle(StartStreamCommand {
1724                session_id: session_id.into(),
1725                stream_id: stream_id.into(),
1726            })
1727            .await
1728            .unwrap();
1729
1730        // `BatchGenerateFramesCommand::max_frames` is deliberately left
1731        // unvalidated (#438) — zero is accepted and truncates to nothing
1732        // rather than being rejected.
1733        let frames = handler
1734            .handle(BatchGenerateFramesCommand {
1735                session_id: session_id.into(),
1736                priority_threshold: crate::application::dto::PriorityDto::new(1).unwrap(),
1737                max_frames: 0,
1738            })
1739            .await
1740            .unwrap();
1741
1742        assert!(frames.is_empty());
1743    }
1744
1745    /// Regression test: a session already closed by the time
1746    /// `BatchGenerateFramesCommand` reaches `batch_generate_frames_atomic`
1747    /// must surface as `ApplicationError::Domain(InvalidSessionState)`, not
1748    /// `NotFound` — the session still exists, it is just no longer active.
1749    /// This is the same outcome a `CloseSessionCommand` racing ahead of a
1750    /// concurrent `BatchGenerateFramesCommand` on the same session would
1751    /// produce.
1752    #[tokio::test]
1753    async fn test_batch_generate_frames_closed_session_returns_domain_error() {
1754        let repository = Arc::new(MockRepository::new());
1755        let event_publisher = Arc::new(MockEventPublisher);
1756        let handler = SessionCommandHandler::new(repository, event_publisher);
1757
1758        let session_id = handler
1759            .handle(CreateSessionCommand {
1760                config: SessionConfig::default(),
1761                client_info: None,
1762                user_agent: None,
1763                ip_address: None,
1764            })
1765            .await
1766            .unwrap();
1767
1768        handler
1769            .handle(CloseSessionCommand {
1770                session_id: session_id.into(),
1771            })
1772            .await
1773            .unwrap();
1774
1775        let result = handler
1776            .handle(BatchGenerateFramesCommand {
1777                session_id: session_id.into(),
1778                priority_threshold: crate::application::dto::PriorityDto::new(1).unwrap(),
1779                max_frames: 8,
1780            })
1781            .await;
1782
1783        assert!(matches!(
1784            result,
1785            Err(ApplicationError::Domain(
1786                crate::domain::DomainError::InvalidSessionState(_)
1787            ))
1788        ));
1789    }
1790
1791    /// Regression test for #477: `BatchGenerateFramesCommand` must not lose
1792    /// `SessionStats` updates to a concurrent atomic command on the same
1793    /// session, the way the old `find_session` + mutate + `save_session`
1794    /// path could. Routing through `batch_generate_frames_atomic` means both
1795    /// commands now share the same per-session lock as every other
1796    /// `*_atomic` method.
1797    #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
1798    async fn test_batch_generate_frames_concurrent_with_complete_stream_loses_no_update() {
1799        let repository = Arc::new(MockRepository::new());
1800        let event_publisher = Arc::new(MockEventPublisher);
1801        let handler = Arc::new(SessionCommandHandler::new(repository, event_publisher));
1802
1803        let session_id = handler
1804            .handle(CreateSessionCommand {
1805                config: SessionConfig::default(),
1806                client_info: None,
1807                user_agent: None,
1808                ip_address: None,
1809            })
1810            .await
1811            .unwrap();
1812
1813        // `frame_stream_id` stays active for the whole test so every
1814        // concurrent batch call has data to draw from, regardless of
1815        // ordering relative to `complete_stream_id`'s completion —
1816        // `create_priority_frames` skips inactive streams, so completing the
1817        // *only* stream mid-race would make batch calls racing after it
1818        // legitimately (not due to a lost update) return zero frames.
1819        let frame_stream_id = handler
1820            .handle(CreateStreamCommand {
1821                session_id: session_id.into(),
1822                source_data: serde_json::json!({"a": 1, "b": 2}).into(),
1823                config: None,
1824            })
1825            .await
1826            .unwrap();
1827        handler
1828            .handle(StartStreamCommand {
1829                session_id: session_id.into(),
1830                stream_id: frame_stream_id.into(),
1831            })
1832            .await
1833            .unwrap();
1834
1835        let complete_stream_id = handler
1836            .handle(CreateStreamCommand {
1837                session_id: session_id.into(),
1838                source_data: serde_json::json!({"c": 3}).into(),
1839                config: None,
1840            })
1841            .await
1842            .unwrap();
1843        handler
1844            .handle(StartStreamCommand {
1845                session_id: session_id.into(),
1846                stream_id: complete_stream_id.into(),
1847            })
1848            .await
1849            .unwrap();
1850
1851        const N: usize = 25;
1852        let barrier = Arc::new(tokio::sync::Barrier::new(N + 1));
1853
1854        let mut handles = Vec::with_capacity(N + 1);
1855        for _ in 0..N {
1856            let handler = Arc::clone(&handler);
1857            let barrier = Arc::clone(&barrier);
1858            handles.push(tokio::spawn(async move {
1859                barrier.wait().await;
1860                handler
1861                    .handle(BatchGenerateFramesCommand {
1862                        session_id: session_id.into(),
1863                        priority_threshold: crate::application::dto::PriorityDto::new(1).unwrap(),
1864                        max_frames: 2,
1865                    })
1866                    .await
1867                    .unwrap()
1868            }));
1869        }
1870        {
1871            let handler = Arc::clone(&handler);
1872            let barrier = Arc::clone(&barrier);
1873            handles.push(tokio::spawn(async move {
1874                barrier.wait().await;
1875                handler
1876                    .handle(CompleteStreamCommand {
1877                        session_id: session_id.into(),
1878                        stream_id: complete_stream_id.into(),
1879                        checksum: None,
1880                    })
1881                    .await
1882                    .unwrap();
1883                vec![]
1884            }));
1885        }
1886
1887        let mut total_frames = 0usize;
1888        for handle in handles {
1889            total_frames += handle.await.unwrap().len();
1890        }
1891        assert_eq!(total_frames, N * 2);
1892
1893        let session = handler
1894            .repository
1895            .find_session(session_id)
1896            .await
1897            .unwrap()
1898            .unwrap();
1899        assert_eq!(session.stats().total_frames, (N * 2) as u64);
1900        assert_eq!(session.stats().completed_streams, 1);
1901    }
1902
1903    #[tokio::test]
1904    async fn test_create_session_rejects_session_timeout_that_would_panic_chrono() {
1905        let repository = Arc::new(MockRepository::new());
1906        let event_publisher = Arc::new(MockEventPublisher);
1907        let handler = SessionCommandHandler::new(repository, event_publisher);
1908
1909        // Reproduces the S1 panic: `session_timeout_seconds as i64` overflowed
1910        // `chrono::Duration::seconds`'s valid range and panicked inside
1911        // `StreamSession::with_time_provider` before this bound existed.
1912        let config = SessionConfig {
1913            session_timeout_seconds: 9_223_372_036_854_776,
1914            ..Default::default()
1915        };
1916
1917        let result = handler
1918            .handle(CreateSessionCommand {
1919                config,
1920                client_info: None,
1921                user_agent: None,
1922                ip_address: None,
1923            })
1924            .await;
1925
1926        assert!(matches!(result, Err(ApplicationError::Validation(_))));
1927    }
1928
1929    #[tokio::test]
1930    async fn test_create_session_rejects_session_timeout_that_would_wrap_negative() {
1931        let repository = Arc::new(MockRepository::new());
1932        let event_publisher = Arc::new(MockEventPublisher);
1933        let handler = SessionCommandHandler::new(repository, event_publisher);
1934
1935        // Reproduces the S1 silent-wraparound case: `u64::MAX as i64` is -1,
1936        // which previously created a session already expired on arrival.
1937        let config = SessionConfig {
1938            session_timeout_seconds: u64::MAX,
1939            ..Default::default()
1940        };
1941
1942        let result = handler
1943            .handle(CreateSessionCommand {
1944                config,
1945                client_info: None,
1946                user_agent: None,
1947                ip_address: None,
1948            })
1949            .await;
1950
1951        assert!(matches!(result, Err(ApplicationError::Validation(_))));
1952    }
1953
1954    /// Regression test for #466: `save_and_publish` must drain
1955    /// `pending_events` before persisting the session, so a later command on
1956    /// the same session neither finds stale events still queued in the
1957    /// persisted copy nor republishes an earlier command's events.
1958    #[tokio::test]
1959    async fn test_save_and_publish_does_not_leak_pending_events_across_commands() {
1960        let repository = Arc::new(MockRepository::new());
1961        let event_publisher = Arc::new(RecordingEventPublisher::new());
1962        let handler = SessionCommandHandler::new(repository.clone(), event_publisher.clone());
1963
1964        let session_id = handler
1965            .handle(CreateSessionCommand {
1966                config: SessionConfig::default(),
1967                client_info: None,
1968                user_agent: None,
1969                ip_address: None,
1970            })
1971            .await
1972            .unwrap();
1973
1974        let after_create_session = repository.find_session(session_id).await.unwrap().unwrap();
1975        assert!(
1976            after_create_session.pending_events().is_empty(),
1977            "persisted session must not carry undrained events after CreateSessionCommand"
1978        );
1979
1980        let stream_id = handler
1981            .handle(CreateStreamCommand {
1982                session_id: session_id.into(),
1983                source_data: serde_json::json!({"test": "data"}).into(),
1984                config: None,
1985            })
1986            .await
1987            .unwrap();
1988
1989        let after_create_stream = repository.find_session(session_id).await.unwrap().unwrap();
1990        assert!(
1991            after_create_stream.pending_events().is_empty(),
1992            "persisted session must not carry undrained events after CreateStreamCommand"
1993        );
1994
1995        handler
1996            .handle(StartStreamCommand {
1997                session_id: session_id.into(),
1998                stream_id: stream_id.into(),
1999            })
2000            .await
2001            .unwrap();
2002
2003        let after_start_stream = repository.find_session(session_id).await.unwrap().unwrap();
2004        assert!(
2005            after_start_stream.pending_events().is_empty(),
2006            "persisted session must not carry undrained events after StartStreamCommand"
2007        );
2008
2009        let batches = event_publisher.batches();
2010        assert_eq!(
2011            batches.len(),
2012            3,
2013            "each command's save_and_publish call must publish exactly one batch"
2014        );
2015        let (create_session_events, create_stream_events, start_stream_events) =
2016            (&batches[0], &batches[1], &batches[2]);
2017
2018        // Without the fix, each persisted clone still carried its
2019        // predecessor's undrained events, so every later batch grew to
2020        // include every earlier command's events too.
2021        assert_eq!(
2022            create_session_events.len(),
2023            1,
2024            "CreateSessionCommand must publish only its own SessionActivated event"
2025        );
2026        assert_eq!(
2027            create_stream_events.len(),
2028            1,
2029            "CreateStreamCommand must publish only its own StreamCreated event, not a republish of CreateSessionCommand's"
2030        );
2031        assert_eq!(
2032            start_stream_events.len(),
2033            1,
2034            "StartStreamCommand must publish only its own StreamStarted event, not a republish of earlier commands'"
2035        );
2036
2037        for event in create_stream_events {
2038            assert!(
2039                !create_session_events.contains(event),
2040                "CreateStreamCommand republished an event from CreateSessionCommand: {event:?}"
2041            );
2042        }
2043        for event in start_stream_events {
2044            assert!(
2045                !create_session_events.contains(event) && !create_stream_events.contains(event),
2046                "StartStreamCommand republished an earlier command's event: {event:?}"
2047            );
2048        }
2049    }
2050}