@@ -592,3 +592,204 @@ describe('MessagingService — inbox read API (ADR-0030)', () => {
592592 expect ( await svc . listInbox ( '' ) ) . toEqual ( { notifications : [ ] , unreadCount : 0 } ) ;
593593 } ) ;
594594} ) ;
595+
596+ /**
597+ * `n` inbox rows for one user, oldest first. `created_at` carries a padded
598+ * millisecond index so the fake engine's lexicographic `desc` sort is the real
599+ * newest-first order for any `n` — the window tests below all depend on
600+ * knowing exactly WHICH rows a truncated window holds.
601+ */
602+ function seedInbox (
603+ userId : string ,
604+ n : number ,
605+ topicAt : ( i : number ) => string = ( ) => 'task.assigned' ,
606+ ) : Array < Record < string , unknown > > {
607+ return Array . from ( { length : n } , ( _ , i ) => ( {
608+ id : `m${ i + 1 } ` ,
609+ user_id : userId ,
610+ notification_id : `n${ i + 1 } ` ,
611+ topic : topicAt ( i ) ,
612+ title : `Notification ${ i + 1 } ` ,
613+ body_md : 'body' ,
614+ created_at : `2026-01-01T00:00:00.${ String ( i ) . padStart ( 3 , '0' ) } Z` ,
615+ } ) ) ;
616+ }
617+
618+ /** An inbox receipt in a read state, for the message `seedInbox` numbered `i`. */
619+ function readReceipt ( userId : string , i : number ) : Record < string , unknown > {
620+ return { id : `r${ i } ` , notification_id : `n${ i } ` , user_id : userId , channel : 'inbox' , state : 'read' } ;
621+ }
622+
623+ /**
624+ * Record every `find` an existing engine is asked to run, in order. Wraps the
625+ * double rather than declaring another one: the cost claims below are about how
626+ * many reads `listInbox` issues, which is only observable at the call site.
627+ */
628+ function recordFinds ( engine : any ) : Array < { object : string ; query : any } > {
629+ const calls : Array < { object : string ; query : any } > = [ ] ;
630+ const real = engine . find . bind ( engine ) ;
631+ engine . find = async ( object : string , query : any = { } ) => {
632+ calls . push ( { object, query } ) ;
633+ return real ( object , query ) ;
634+ } ;
635+ return calls ;
636+ }
637+
638+ /**
639+ * [#6363] `ListNotificationsResponseSchema.unreadCount` is published into the
640+ * API reference as "Total number of unread notifications". It was counted
641+ * inside `rows.map(...)`, i.e. over the `limit`-truncated window, so the badge
642+ * saturated at the window size forever: measured on a real stack with 60
643+ * unread, the route answered `unreadCount: 50` unfiltered and `10` at
644+ * `?limit=10`. Maintainer ruling (2026-08-07, Option A): make the declaration
645+ * true — count the total — and leave the list itself windowed.
646+ *
647+ * The fixtures below are the issue's measured shape (60 unread, `limit=10`).
648+ */
649+ describe ( '[#6363] listInbox — unreadCount is the TOTAL unread, not the fetched window' , ( ) => {
650+ const logger = silentLogger ( ) ;
651+
652+ it ( 'counts every unread message when the window truncates the inbox' , async ( ) => {
653+ const engine = inboxEngine ( { inbox : seedInbox ( 'u1' , 60 ) } ) ;
654+ const svc = new MessagingService ( { logger, getData : ( ) => engine } ) ;
655+
656+ // No `limit`: the default clamp windows the LIST at 50 — unchanged.
657+ const unfiltered = await svc . listInbox ( 'u1' ) ;
658+ expect ( unfiltered . notifications ) . toHaveLength ( 50 ) ;
659+ expect ( unfiltered . unreadCount ) . toBe ( 60 ) ; // was 50 — the window's size
660+
661+ // `?limit=10`: the list shrinks with the window, the badge does not.
662+ const windowed = await svc . listInbox ( 'u1' , { limit : 10 } ) ;
663+ expect ( windowed . notifications ) . toHaveLength ( 10 ) ;
664+ expect ( windowed . unreadCount ) . toBe ( 60 ) ; // was 10 — the window's size
665+ } ) ;
666+
667+ it ( 'subtracts read-state across the whole inbox, not just inside the window' , async ( ) => {
668+ // The 20 read messages are the OLDEST, so none of them is inside a
669+ // newest-first `limit=10` window: a window-scoped count cannot see
670+ // them, and a total that ignored receipts would answer 60.
671+ const engine = inboxEngine ( {
672+ inbox : seedInbox ( 'u1' , 60 ) ,
673+ receipts : Array . from ( { length : 20 } , ( _ , i ) => readReceipt ( 'u1' , i + 1 ) ) ,
674+ } ) ;
675+ const svc = new MessagingService ( { logger, getData : ( ) => engine } ) ;
676+
677+ const res = await svc . listInbox ( 'u1' , { limit : 10 } ) ;
678+ expect ( res . notifications ) . toHaveLength ( 10 ) ;
679+ expect ( res . notifications . every ( ( n ) => n . read === false ) ) . toBe ( true ) ; // window is all-unread
680+ expect ( res . unreadCount ) . toBe ( 40 ) ;
681+ } ) ;
682+
683+ it ( 'counts rows carrying no notification_id — never receipted, so never read' , async ( ) => {
684+ const inbox = seedInbox ( 'u1' , 60 ) ;
685+ // A synthetic/legacy row with no event id keys no receipt at all.
686+ for ( const row of inbox . slice ( 0 , 5 ) ) row . notification_id = null ;
687+ const engine = inboxEngine ( { inbox } ) ;
688+ const svc = new MessagingService ( { logger, getData : ( ) => engine } ) ;
689+
690+ expect ( ( await svc . listInbox ( 'u1' , { limit : 10 } ) ) . unreadCount ) . toBe ( 60 ) ;
691+ } ) ;
692+
693+ it ( 'answers the `type` filter it was asked, over the whole inbox' , async ( ) => {
694+ // 60 messages alternating between two topics ⇒ 30 of each.
695+ const engine = inboxEngine ( {
696+ inbox : seedInbox ( 'u1' , 60 , ( i ) => ( i % 2 === 0 ? 'deal.won' : 'task.assigned' ) ) ,
697+ } ) ;
698+ const svc = new MessagingService ( { logger, getData : ( ) => engine } ) ;
699+
700+ const res = await svc . listInbox ( 'u1' , { type : 'deal.won' , limit : 10 } ) ;
701+ expect ( res . notifications ) . toHaveLength ( 10 ) ;
702+ expect ( res . notifications . every ( ( n ) => n . type === 'deal.won' ) ) . toBe ( true ) ;
703+ expect ( res . unreadCount ) . toBe ( 30 ) ;
704+ } ) ;
705+
706+ it ( 'counts only the addressed user, at any window size' , async ( ) => {
707+ const engine = inboxEngine ( { inbox : [ ...seedInbox ( 'u1' , 60 ) , ...seedInbox ( 'u2' , 7 ) ] } ) ;
708+ const svc = new MessagingService ( { logger, getData : ( ) => engine } ) ;
709+
710+ expect ( ( await svc . listInbox ( 'u1' , { limit : 10 } ) ) . unreadCount ) . toBe ( 60 ) ;
711+ expect ( ( await svc . listInbox ( 'u2' , { limit : 10 } ) ) . unreadCount ) . toBe ( 7 ) ;
712+ } ) ;
713+
714+ it ( 'the `read` filter narrows the list and never the badge' , async ( ) => {
715+ const engine = inboxEngine ( {
716+ inbox : seedInbox ( 'u1' , 60 ) ,
717+ receipts : Array . from ( { length : 20 } , ( _ , i ) => readReceipt ( 'u1' , i + 1 ) ) ,
718+ } ) ;
719+ const svc = new MessagingService ( { logger, getData : ( ) => engine } ) ;
720+
721+ // Asking for the READ half does not mean the unread badge is zero.
722+ const readOnly = await svc . listInbox ( 'u1' , { read : true , limit : 10 } ) ;
723+ expect ( readOnly . notifications ) . toEqual ( [ ] ) ; // the newest 10 are all unread
724+ expect ( readOnly . unreadCount ) . toBe ( 40 ) ;
725+
726+ const unreadOnly = await svc . listInbox ( 'u1' , { read : false , limit : 10 } ) ;
727+ expect ( unreadOnly . notifications ) . toHaveLength ( 10 ) ;
728+ expect ( unreadOnly . unreadCount ) . toBe ( 40 ) ;
729+ } ) ;
730+
731+ it ( 'a window that came back SHORT costs no second read' , async ( ) => {
732+ // Nothing was truncated, so the window count already IS the total and
733+ // the reverse join would re-read what the first `find` returned.
734+ const engine = inboxEngine ( { inbox : seedInbox ( 'u1' , 3 ) } ) ;
735+ const calls = recordFinds ( engine ) ;
736+ const svc = new MessagingService ( { logger, getData : ( ) => engine } ) ;
737+
738+ const res = await svc . listInbox ( 'u1' ) ;
739+ expect ( res . unreadCount ) . toBe ( 3 ) ;
740+ expect ( calls . filter ( ( c ) => c . object === 'sys_inbox_message' ) ) . toHaveLength ( 1 ) ;
741+ } ) ;
742+
743+ it ( 'the total read is one narrow projection, unwindowed, over the same predicate' , async ( ) => {
744+ const engine = inboxEngine ( { inbox : seedInbox ( 'u1' , 60 ) } ) ;
745+ const calls = recordFinds ( engine ) ;
746+ const svc = new MessagingService ( { logger, getData : ( ) => engine } ) ;
747+
748+ await svc . listInbox ( 'u1' , { type : 'task.assigned' , limit : 10 } ) ;
749+
750+ const inboxReads = calls . filter ( ( c ) => c . object === 'sys_inbox_message' ) ;
751+ expect ( inboxReads ) . toHaveLength ( 2 ) ;
752+ const [ windowRead , totalRead ] = inboxReads ;
753+ expect ( windowRead . query . limit ) . toBe ( 10 ) ;
754+ // Same predicate as the windowed read — the count answers the same
755+ // question, minus the window — and one column, so the extra work stays
756+ // the same order as the receipt scan `listInbox` already performs.
757+ expect ( totalRead . query . where ) . toEqual ( windowRead . query . where ) ;
758+ expect ( totalRead . query . limit ) . toBeUndefined ( ) ;
759+ expect ( totalRead . query . fields ) . toEqual ( [ 'notification_id' ] ) ;
760+ } ) ;
761+
762+ it ( 'a failing total read is NOT swallowed into a window-sized answer' , async ( ) => {
763+ // The receipt read degrades (different object, may be absent from a
764+ // minimal stack); this one re-reads the object whose `find` just
765+ // succeeded, so a failure is a data-layer outage — not a licence to
766+ // quietly re-tell the window-sized lie.
767+ const engine = inboxEngine ( { inbox : seedInbox ( 'u1' , 60 ) } ) ;
768+ const real = engine . find . bind ( engine ) ;
769+ engine . find = async ( object : string , query : any = { } ) => {
770+ if ( object === 'sys_inbox_message' && query . fields ) throw new Error ( 'connection lost' ) ;
771+ return real ( object , query ) ;
772+ } ;
773+ const svc = new MessagingService ( { logger, getData : ( ) => engine } ) ;
774+
775+ await expect ( svc . listInbox ( 'u1' , { limit : 10 } ) ) . rejects . toThrow ( 'connection lost' ) ;
776+ } ) ;
777+
778+ /* ------------------------------------------------------------------ */
779+ /* The other half of the ruling: the LIST window is unchanged. */
780+ /* ------------------------------------------------------------------ */
781+
782+ it ( 'the list keeps its window: default 50, hard cap 200, floor 1, newest first' , async ( ) => {
783+ const engine = inboxEngine ( { inbox : seedInbox ( 'u1' , 250 ) } ) ;
784+ const svc = new MessagingService ( { logger, getData : ( ) => engine } ) ;
785+
786+ const dflt = await svc . listInbox ( 'u1' ) ;
787+ expect ( dflt . notifications ) . toHaveLength ( 50 ) ;
788+ expect ( dflt . notifications [ 0 ] . id ) . toBe ( 'n250' ) ; // newest first, still
789+ expect ( dflt . unreadCount ) . toBe ( 250 ) ;
790+
791+ expect ( ( await svc . listInbox ( 'u1' , { limit : 500 } ) ) . notifications ) . toHaveLength ( 200 ) ;
792+ expect ( ( await svc . listInbox ( 'u1' , { limit : 0 } ) ) . notifications ) . toHaveLength ( 1 ) ;
793+ expect ( ( await svc . listInbox ( 'u1' , { limit : 120 } ) ) . notifications ) . toHaveLength ( 120 ) ;
794+ } ) ;
795+ } ) ;
0 commit comments