@@ -425,3 +425,140 @@ describe('MessagingService', () => {
425425 } ) ;
426426 } ) ;
427427} ) ;
428+
429+ /**
430+ * A stateful in-memory engine for the inbox read API (ADR-0030). Supports the
431+ * flat-equality `where` filters listInbox/markRead/markAllRead issue, plus
432+ * `update(..., { where: { id } })` mutation and `insert`.
433+ */
434+ function inboxEngine ( seed : { inbox ?: any [ ] ; receipts ?: any [ ] } = { } ) {
435+ const store : Record < string , any [ ] > = {
436+ sys_inbox_message : [ ...( seed . inbox ?? [ ] ) ] ,
437+ sys_notification_receipt : [ ...( seed . receipts ?? [ ] ) ] ,
438+ } ;
439+ let seq = 0 ;
440+ const matches = ( row : any , where : any = { } ) =>
441+ Object . entries ( where ) . every ( ( [ k , v ] ) => String ( row [ k ] ) === String ( v ) ) ;
442+ const engine = {
443+ store,
444+ async find ( object : string , query : any = { } ) {
445+ let rows = ( store [ object ] ?? [ ] ) . filter ( ( r ) => matches ( r , query . where ) ) ;
446+ const ob = Array . isArray ( query . orderBy ) ? query . orderBy : [ ] ;
447+ if ( ob . some ( ( o : any ) => o . field === 'created_at' && o . order === 'desc' ) ) {
448+ rows = [ ...rows ] . sort ( ( a , b ) => String ( b . created_at ) . localeCompare ( String ( a . created_at ) ) ) ;
449+ }
450+ return typeof query . limit === 'number' ? rows . slice ( 0 , query . limit ) : rows ;
451+ } ,
452+ async findOne ( object : string , query : any = { } ) {
453+ return ( store [ object ] ?? [ ] ) . find ( ( r ) => matches ( r , query . where ) ) ?? null ;
454+ } ,
455+ async insert ( object : string , row : any ) {
456+ const created = { id : `row_${ ++ seq } ` , ...row } ;
457+ ( store [ object ] ??= [ ] ) . push ( created ) ;
458+ return created ;
459+ } ,
460+ async update ( object : string , data : any , options : any = { } ) {
461+ for ( const r of store [ object ] ?? [ ] ) {
462+ if ( matches ( r , options . where ) ) Object . assign ( r , data ) ;
463+ }
464+ return { } ;
465+ } ,
466+ async delete ( ) { return { } ; } ,
467+ async count ( ) { return 0 ; } ,
468+ async aggregate ( ) { return [ ] ; } ,
469+ } ;
470+ return engine as any ;
471+ }
472+
473+ describe ( 'MessagingService — inbox read API (ADR-0030)' , ( ) => {
474+ const logger = silentLogger ( ) ;
475+
476+ it ( 'lists inbox rows joined with receipt read-state and counts unread' , async ( ) => {
477+ const engine = inboxEngine ( {
478+ inbox : [
479+ { id : 'm1' , user_id : 'u1' , notification_id : 'n1' , topic : 'collab.mention' , title : 'A' , body_md : 'a' , action_url : '/x' , created_at : '2026-01-01T00:00:01Z' } ,
480+ { id : 'm2' , user_id : 'u1' , notification_id : 'n2' , topic : 'task.assigned' , title : 'B' , body_md : 'b' , created_at : '2026-01-01T00:00:02Z' } ,
481+ { id : 'm3' , user_id : 'u2' , notification_id : 'n3' , topic : 'x' , title : 'C' , created_at : '2026-01-01T00:00:03Z' } ,
482+ ] ,
483+ receipts : [
484+ { id : 'r1' , notification_id : 'n1' , user_id : 'u1' , channel : 'inbox' , state : 'read' } ,
485+ { id : 'r2' , notification_id : 'n2' , user_id : 'u1' , channel : 'inbox' , state : 'delivered' } ,
486+ ] ,
487+ } ) ;
488+ const svc = new MessagingService ( { logger, getData : ( ) => engine } ) ;
489+
490+ const res = await svc . listInbox ( 'u1' ) ;
491+ // Only u1's rows; newest first; n2 unread, n1 read.
492+ expect ( res . notifications . map ( ( n ) => n . id ) ) . toEqual ( [ 'n2' , 'n1' ] ) ;
493+ expect ( res . unreadCount ) . toBe ( 1 ) ;
494+ const n1 = res . notifications . find ( ( n ) => n . id === 'n1' ) ! ;
495+ expect ( n1 ) . toMatchObject ( { type : 'collab.mention' , title : 'A' , body : 'a' , read : true , actionUrl : '/x' } ) ;
496+ expect ( res . notifications . find ( ( n ) => n . id === 'n2' ) ! . read ) . toBe ( false ) ;
497+ } ) ;
498+
499+ it ( 'filters by read state when requested' , async ( ) => {
500+ const engine = inboxEngine ( {
501+ inbox : [
502+ { id : 'm1' , user_id : 'u1' , notification_id : 'n1' , title : 'A' , created_at : '1' } ,
503+ { id : 'm2' , user_id : 'u1' , notification_id : 'n2' , title : 'B' , created_at : '2' } ,
504+ ] ,
505+ receipts : [ { id : 'r1' , notification_id : 'n1' , user_id : 'u1' , channel : 'inbox' , state : 'read' } ] ,
506+ } ) ;
507+ const svc = new MessagingService ( { logger, getData : ( ) => engine } ) ;
508+
509+ expect ( ( await svc . listInbox ( 'u1' , { read : false } ) ) . notifications . map ( ( n ) => n . id ) ) . toEqual ( [ 'n2' ] ) ;
510+ expect ( ( await svc . listInbox ( 'u1' , { read : true } ) ) . notifications . map ( ( n ) => n . id ) ) . toEqual ( [ 'n1' ] ) ;
511+ } ) ;
512+
513+ it ( 'markRead updates the existing delivered receipt in place (no duplicate)' , async ( ) => {
514+ const engine = inboxEngine ( {
515+ inbox : [ { id : 'm1' , user_id : 'u1' , notification_id : 'n1' , title : 'A' , created_at : '1' } ] ,
516+ receipts : [ { id : 'r1' , notification_id : 'n1' , user_id : 'u1' , channel : 'inbox' , state : 'delivered' } ] ,
517+ } ) ;
518+ const svc = new MessagingService ( { logger, getData : ( ) => engine } ) ;
519+
520+ const res = await svc . markRead ( 'u1' , [ 'n1' ] ) ;
521+ expect ( res ) . toEqual ( { success : true , readCount : 1 } ) ;
522+ const receipts = engine . store . sys_notification_receipt ;
523+ expect ( receipts ) . toHaveLength ( 1 ) ; // updated in place, not duplicated
524+ expect ( receipts [ 0 ] ) . toMatchObject ( { id : 'r1' , state : 'read' } ) ;
525+ expect ( receipts [ 0 ] . at ) . toBeTruthy ( ) ;
526+ } ) ;
527+
528+ it ( 'markRead inserts a read receipt when none exists yet' , async ( ) => {
529+ const engine = inboxEngine ( {
530+ inbox : [ { id : 'm1' , user_id : 'u1' , notification_id : 'n1' , title : 'A' , created_at : '1' } ] ,
531+ } ) ;
532+ const svc = new MessagingService ( { logger, getData : ( ) => engine } ) ;
533+
534+ const res = await svc . markRead ( 'u1' , [ 'n1' ] ) ;
535+ expect ( res . readCount ) . toBe ( 1 ) ;
536+ const receipts = engine . store . sys_notification_receipt ;
537+ expect ( receipts ) . toHaveLength ( 1 ) ;
538+ expect ( receipts [ 0 ] ) . toMatchObject ( { notification_id : 'n1' , user_id : 'u1' , channel : 'inbox' , state : 'read' } ) ;
539+ } ) ;
540+
541+ it ( 'markAllRead flips every unread message and leaves already-read ones' , async ( ) => {
542+ const engine = inboxEngine ( {
543+ inbox : [
544+ { id : 'm1' , user_id : 'u1' , notification_id : 'n1' , title : 'A' , created_at : '1' } ,
545+ { id : 'm2' , user_id : 'u1' , notification_id : 'n2' , title : 'B' , created_at : '2' } ,
546+ ] ,
547+ receipts : [ { id : 'r1' , notification_id : 'n1' , user_id : 'u1' , channel : 'inbox' , state : 'read' } ] ,
548+ } ) ;
549+ const svc = new MessagingService ( { logger, getData : ( ) => engine } ) ;
550+
551+ const res = await svc . markAllRead ( 'u1' ) ;
552+ expect ( res . readCount ) . toBe ( 1 ) ; // only n2 was unread
553+ expect ( ( await svc . listInbox ( 'u1' ) ) . unreadCount ) . toBe ( 0 ) ;
554+ } ) ;
555+
556+ it ( 'degrades to empty without a data engine or user id' , async ( ) => {
557+ const noData = new MessagingService ( { logger } ) ;
558+ expect ( await noData . listInbox ( 'u1' ) ) . toEqual ( { notifications : [ ] , unreadCount : 0 } ) ;
559+ expect ( await noData . markRead ( 'u1' , [ 'n1' ] ) ) . toEqual ( { success : true , readCount : 0 } ) ;
560+
561+ const svc = new MessagingService ( { logger, getData : ( ) => inboxEngine ( ) } ) ;
562+ expect ( await svc . listInbox ( '' ) ) . toEqual ( { notifications : [ ] , unreadCount : 0 } ) ;
563+ } ) ;
564+ } ) ;
0 commit comments