@@ -793,3 +793,192 @@ describe('[#6363] listInbox — unreadCount is the TOTAL unread, not the fetched
793793 expect ( ( await svc . listInbox ( 'u1' , { limit : 120 } ) ) . notifications ) . toHaveLength ( 120 ) ;
794794 } ) ;
795795} ) ;
796+
797+ /**
798+ * [#6436] `markAllRead` swept `listInbox(userId, { read: false, limit: 200 })`
799+ * — one page of the LIST — and `200` is that list's hard cap, so the route
800+ * documented as "mark **every** currently-unread inbox message as read"
801+ * cleared at most 200 receipts per call.
802+ *
803+ * #6363 did not introduce this; it removed the cover. While `unreadCount` was
804+ * counted over the window the truncation was self-consistent and invisible
805+ * (clear 200, poll, see a window with nothing unread in it, badge 0). Now that
806+ * the badge is the true total, one response pair states the contradiction on
807+ * its own: `POST /read/all → { readCount: 200 }` then
808+ * `GET /notifications → { unreadCount: 150 }`.
809+ *
810+ * Route C — redefine "all" as "the current window" — was excluded by the
811+ * maintainer's #6363 Option A ruling (make the declaration true). The sweep now
812+ * reads the unread SET directly instead of a page of the list.
813+ */
814+ describe ( '[#6436] markAllRead — sweeps the whole inbox, not one 200-row window' , ( ) => {
815+ const logger = silentLogger ( ) ;
816+
817+ it ( "clears an inbox holding more unread than the list's hard cap (the issue's 350)" , async ( ) => {
818+ const engine = inboxEngine ( { inbox : seedInbox ( 'u1' , 350 ) } ) ;
819+ const svc = new MessagingService ( { logger, getData : ( ) => engine } ) ;
820+
821+ const res = await svc . markAllRead ( 'u1' ) ;
822+ // Before: `readCount: 200`, and 150 messages still unread behind a
823+ // badge that — since #6363 — reported them correctly.
824+ expect ( res ) . toEqual ( { success : true , readCount : 350 } ) ;
825+ expect ( ( await svc . listInbox ( 'u1' ) ) . unreadCount ) . toBe ( 0 ) ;
826+
827+ // Persisted read-state, not a view-layer computation: one receipt per
828+ // notification, every one of them `read`.
829+ const receipts = engine . store . sys_notification_receipt ;
830+ expect ( receipts ) . toHaveLength ( 350 ) ;
831+ expect ( receipts . every ( ( r : any ) => r . state === 'read' ) ) . toBe ( true ) ;
832+ } ) ;
833+
834+ it ( 'marks the older unread even when the newest 200 are already read' , async ( ) => {
835+ // The sharper face of the same defect, and the reason "loop `listInbox`
836+ // until it comes back empty" is not merely costly but WRONG: the window
837+ // is `created_at desc` over ALL rows and the `read` filter is applied
838+ // in memory AFTER the truncation. An inbox whose newest 200 are read
839+ // therefore handed the sweep an EMPTY id list — it marked nothing at
840+ // all, however much older unread sat behind it, and a paging loop would
841+ // have exited on that same empty first page. Reading the unread SET
842+ // makes a message's position in the inbox stop mattering.
843+ const engine = inboxEngine ( {
844+ inbox : seedInbox ( 'u1' , 350 ) ,
845+ // m151…m350 are the NEWEST 200 (created_at .150 … .349).
846+ receipts : Array . from ( { length : 200 } , ( _ , i ) => readReceipt ( 'u1' , i + 151 ) ) ,
847+ } ) ;
848+ const svc = new MessagingService ( { logger, getData : ( ) => engine } ) ;
849+ expect ( ( await svc . listInbox ( 'u1' ) ) . unreadCount ) . toBe ( 150 ) ;
850+
851+ expect ( ( await svc . markAllRead ( 'u1' ) ) . readCount ) . toBe ( 150 ) ; // before: 0
852+ expect ( ( await svc . listInbox ( 'u1' ) ) . unreadCount ) . toBe ( 0 ) ;
853+ } ) ;
854+
855+ it ( 'a small inbox behaves exactly as before — only the unread flip' , async ( ) => {
856+ const engine = inboxEngine ( {
857+ inbox : seedInbox ( 'u1' , 10 ) ,
858+ receipts : [ readReceipt ( 'u1' , 1 ) , readReceipt ( 'u1' , 2 ) ] ,
859+ } ) ;
860+ const before = engine . store . sys_notification_receipt . map ( ( r : any ) => ( { ...r } ) ) ;
861+ const svc = new MessagingService ( { logger, getData : ( ) => engine } ) ;
862+
863+ expect ( await svc . markAllRead ( 'u1' ) ) . toEqual ( { success : true , readCount : 8 } ) ;
864+ expect ( ( await svc . listInbox ( 'u1' ) ) . unreadCount ) . toBe ( 0 ) ;
865+
866+ // The two already-read receipts are not re-stamped: an inbox smaller
867+ // than the old window is the case that was never broken, and it must
868+ // not start doing extra writes to prove it.
869+ const after = engine . store . sys_notification_receipt ;
870+ expect ( after ) . toHaveLength ( 10 ) ;
871+ expect ( after . slice ( 0 , 2 ) ) . toEqual ( before ) ;
872+ } ) ;
873+
874+ it ( 'costs a fixed two reads however large the inbox is — no paging loop' , async ( ) => {
875+ for ( const n of [ 10 , 350 ] ) {
876+ const engine = inboxEngine ( { inbox : seedInbox ( 'u1' , n ) } ) ;
877+ const calls = recordFinds ( engine ) ;
878+ const svc = new MessagingService ( { logger, getData : ( ) => engine } ) ;
879+
880+ await svc . markAllRead ( 'u1' ) ;
881+
882+ expect ( calls , `inbox of ${ n } ` ) . toHaveLength ( 2 ) ;
883+ const inboxRead = calls . find ( ( c ) => c . object === 'sys_inbox_message' ) ! ;
884+ // Unwindowed, unordered and one column wide — the same projection
885+ // #6363's `countUnreadTotal` already reads to answer the badge, so
886+ // the sweep asks the data layer for nothing the bell poll does not
887+ // ask it on every saturated page.
888+ expect ( inboxRead . query . where ) . toEqual ( { user_id : 'u1' } ) ;
889+ expect ( inboxRead . query . fields ) . toEqual ( [ 'notification_id' ] ) ;
890+ expect ( inboxRead . query . limit ) . toBeUndefined ( ) ;
891+ expect ( inboxRead . query . orderBy ) . toBeUndefined ( ) ;
892+ expect ( calls . filter ( ( c ) => c . object === 'sys_notification_receipt' ) ) . toHaveLength ( 1 ) ;
893+ }
894+ } ) ;
895+
896+ it ( 'touches only the addressed user' , async ( ) => {
897+ const engine = inboxEngine ( {
898+ inbox : [
899+ ...seedInbox ( 'u1' , 350 ) ,
900+ { id : 'x1' , user_id : 'u2' , notification_id : 'xn1' , title : 'X' , body_md : 'x' , created_at : '2026-01-01T00:00:00.900Z' } ,
901+ { id : 'x2' , user_id : 'u2' , notification_id : 'xn2' , title : 'Y' , body_md : 'y' , created_at : '2026-01-01T00:00:00.901Z' } ,
902+ ] ,
903+ } ) ;
904+ const svc = new MessagingService ( { logger, getData : ( ) => engine } ) ;
905+
906+ expect ( ( await svc . markAllRead ( 'u1' ) ) . readCount ) . toBe ( 350 ) ;
907+ expect ( ( await svc . listInbox ( 'u2' ) ) . unreadCount ) . toBe ( 2 ) ;
908+ expect ( engine . store . sys_notification_receipt . every ( ( r : any ) => r . user_id === 'u1' ) ) . toBe ( true ) ;
909+ } ) ;
910+
911+ it ( 'is idempotent — a second sweep writes nothing and reports 0' , async ( ) => {
912+ const engine = inboxEngine ( { inbox : seedInbox ( 'u1' , 350 ) } ) ;
913+ const svc = new MessagingService ( { logger, getData : ( ) => engine } ) ;
914+
915+ expect ( ( await svc . markAllRead ( 'u1' ) ) . readCount ) . toBe ( 350 ) ;
916+ expect ( ( await svc . markAllRead ( 'u1' ) ) . readCount ) . toBe ( 0 ) ;
917+ expect ( engine . store . sys_notification_receipt ) . toHaveLength ( 350 ) ;
918+ } ) ;
919+
920+ it ( 'counts a notification once when several inbox rows materialize it' , async ( ) => {
921+ // `readCount` reports NOTIFICATIONS flipped, and the receipt is keyed
922+ // `(notification_id, user_id, channel)` — one row per notification
923+ // however many inbox rows point at it. Feeding the id twice would have
924+ // counted a second upsert that wrote nothing new.
925+ const inbox = seedInbox ( 'u1' , 3 ) ;
926+ inbox [ 2 ] . notification_id = 'n1' ; // m3 re-materializes n1
927+ const engine = inboxEngine ( { inbox } ) ;
928+ const svc = new MessagingService ( { logger, getData : ( ) => engine } ) ;
929+
930+ expect ( ( await svc . markAllRead ( 'u1' ) ) . readCount ) . toBe ( 2 ) ;
931+ expect ( engine . store . sys_notification_receipt ) . toHaveLength ( 2 ) ;
932+ expect ( ( await svc . listInbox ( 'u1' ) ) . unreadCount ) . toBe ( 0 ) ;
933+ } ) ;
934+
935+ it ( 'reads the receipt spine best-effort, exactly as listInbox does' , async ( ) => {
936+ // Read-state lives on a DIFFERENT object which a minimal stack may not
937+ // have registered (`listInbox` degrades to "everything unread" for the
938+ // same reason). Degrading to "sweep them all" is the safe direction:
939+ // re-marking a read message is idempotent, skipping an unread one is
940+ // the defect this issue is about.
941+ const engine = inboxEngine ( {
942+ inbox : seedInbox ( 'u1' , 3 ) ,
943+ receipts : [ readReceipt ( 'u1' , 1 ) ] ,
944+ } ) ;
945+ const real = engine . find . bind ( engine ) ;
946+ engine . find = async ( object : string , query : any = { } ) => {
947+ if ( object === 'sys_notification_receipt' ) throw new Error ( 'receipts unavailable' ) ;
948+ return real ( object , query ) ;
949+ } ;
950+ const svc = new MessagingService ( { logger, getData : ( ) => engine } ) ;
951+
952+ expect ( ( await svc . markAllRead ( 'u1' ) ) . readCount ) . toBe ( 3 ) ;
953+ } ) ;
954+
955+ it ( 'skips rows carrying no event id — they key no receipt at all' , async ( ) => {
956+ // Recorded, NOT endorsed. Read-state is keyed by the EVENT id
957+ // (ADR-0030) and the inbox channel writes no receipt for a row without
958+ // one, so there is nothing this sweep can write for it. The old code
959+ // fed `markRead` the inbox ROW id (`listInbox` views it as `nid ??
960+ // String(m.id)`), which inserted a receipt the join never reads back —
961+ // it could not make the row read and still counted itself into
962+ // `readCount`. Skipping it keeps `readCount` honest; #6363's count goes
963+ // on reporting the row as unread, which is the true state. Whether such
964+ // a row should be readable at all is #6448 — a gap in the receipt KEY,
965+ // not in this sweep, and dormant: the single `emit()` ingress always
966+ // carries an event id, so only data written around it can be null.
967+ const inbox = seedInbox ( 'u1' , 3 ) ;
968+ inbox [ 0 ] . notification_id = null ;
969+ const engine = inboxEngine ( { inbox } ) ;
970+ const svc = new MessagingService ( { logger, getData : ( ) => engine } ) ;
971+
972+ expect ( ( await svc . markAllRead ( 'u1' ) ) . readCount ) . toBe ( 2 ) ;
973+ expect ( engine . store . sys_notification_receipt ) . toHaveLength ( 2 ) ;
974+ expect ( ( await svc . listInbox ( 'u1' ) ) . unreadCount ) . toBe ( 1 ) ;
975+ } ) ;
976+
977+ it ( 'still degrades to a no-op without a data engine or user id' , async ( ) => {
978+ const noData = new MessagingService ( { logger } ) ;
979+ expect ( await noData . markAllRead ( 'u1' ) ) . toEqual ( { success : true , readCount : 0 } ) ;
980+
981+ const svc = new MessagingService ( { logger, getData : ( ) => inboxEngine ( { inbox : seedInbox ( 'u1' , 3 ) } ) } ) ;
982+ expect ( await svc . markAllRead ( '' ) ) . toEqual ( { success : true , readCount : 0 } ) ;
983+ } ) ;
984+ } ) ;
0 commit comments