diff --git a/packages/runtime/src/notification-schema-conformance.integration.test.ts b/packages/runtime/src/notification-schema-conformance.integration.test.ts new file mode 100644 index 0000000000..e830f491cc --- /dev/null +++ b/packages/runtime/src/notification-schema-conformance.integration.test.ts @@ -0,0 +1,270 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// [#5792 / Part of #3877] Stage A, notification family — the WIRE half. +// +// The other two gates check the two halves in isolation: the producer +// (`service-messaging/src/notification-schema-conformance.test.ts`) and the +// dispatcher domain (`./notification-schema-conformance.test.ts`). This one +// checks the composed shape a browser actually receives, over a real socket, +// through a real SQL driver — the same call #5682 made for the REST discovery +// gate, and for the same reason: neither producer alone is the thing a client +// parses. +// +// Two facts are only measurable here: +// +// 1. `createdAt` is `z.string().datetime()`, i.e. an ISO-8601 instant and not +// merely "a string". The value makes a full round trip through +// `sys_inbox_message.created_at` (`Field.datetime()`) and back out of the +// driver. A driver dialect (`2026-01-01 00:00:00`, or a `Date` object) +// would satisfy the in-memory producer gate's fixture and fail here. +// 2. `actionUrl` is written unconditionally as `… ?? undefined`, so +// `Object.keys()` sees the key on every row while `JSON.stringify` drops +// it from the empty ones. The producer gate reads the object view; only +// this file reads the JSON view. Both must conform — the `routes.mcp` +// nuance #5679 measured, in this family. +// +// The boot mirrors `notifications.hono.integration.test.ts` (the #3362 +// regression), deliberately: that suite proved the routes are REACHABLE, this +// one proves what comes back is what the catalog declares. Kept as a separate +// file rather than bolted onto it so the conformance gate can be read, moved +// or ratcheted (#3877 Stage D) without dragging a reachability regression with +// it. + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { ObjectKernel, Plugin, PluginContext } from '@objectstack/core'; +import { HonoServerPlugin } from '@objectstack/plugin-hono-server'; +import { ObjectQLPlugin } from '@objectstack/objectql'; +import { SqliteWasmDriver } from '@objectstack/driver-sqlite-wasm'; +import { MessagingServicePlugin, MessagingService } from '@objectstack/service-messaging'; +import { + envelopeViolations, + ListNotificationsResponseSchema, + MarkNotificationsReadResponseSchema, + MarkAllNotificationsReadResponseSchema, + NotificationSchema, +} from '@objectstack/spec/api'; +import type { IHttpServer } from '@objectstack/spec/contracts'; + +import { createDispatcherPlugin } from './dispatcher-plugin.js'; +import { DriverPlugin } from './driver-plugin.js'; + +// One inbox per concern. The mark-read routes MUTATE read-state, so a shared +// user would make these suites order-dependent — the unread fixture the gap +// suite needs would be consumed by whichever mark-read test ran first. +const LIST_USER = 'usr_notif_conformance_list'; +const MARK_USER = 'usr_notif_conformance_mark'; +const GAP_USER = 'usr_notif_conformance_gap'; + +/** Declared key sets — derived from the schemas, never hand-listed. */ +const declaredListKeys = () => new Set(Object.keys((ListNotificationsResponseSchema as any).shape)); +const declaredNotificationKeys = () => new Set(Object.keys((NotificationSchema as any).shape)); +const declaredMarkReadKeys = () => new Set(Object.keys((MarkNotificationsReadResponseSchema as any).shape)); +const declaredMarkAllReadKeys = () => new Set(Object.keys((MarkAllNotificationsReadResponseSchema as any).shape)); + +/** Minimal `auth` service — `x-test-user` names the principal, absent = anonymous. */ +function fakeAuthPlugin(): Plugin { + return { + name: 'com.objectstack.test.fake-auth-notif-conformance', + version: '1.0.0', + init: async (ctx: PluginContext) => { + ctx.registerService('auth', { + api: { + getSession: async ({ headers }: { headers: any }) => { + const uid = typeof headers?.get === 'function' + ? headers.get('x-test-user') + : headers?.['x-test-user']; + return uid ? { user: { id: uid } } : undefined; + }, + }, + }); + }, + }; +} + +describe('[#5792] the notification wire bodies conform to the schemas the catalog declares', () => { + let kernel: ObjectKernel; + let baseUrl: string; + let messaging: MessagingService; + + beforeAll(async () => { + kernel = new ObjectKernel({ logLevel: 'silent' }); + await kernel.use(new DriverPlugin(new SqliteWasmDriver({ filename: ':memory:' }))); + await kernel.use(new ObjectQLPlugin()); + // Inline delivery so `emit()` materializes the inbox row synchronously. + await kernel.use(new MessagingServicePlugin({ reliableDelivery: false })); + await kernel.use(fakeAuthPlugin()); + await kernel.use(new HonoServerPlugin({ port: 0 })); + await kernel.use(createDispatcherPlugin({ prefix: '/api/v1', securityHeaders: false, requireAuth: false })); + await kernel.bootstrap(); + + const httpServer = kernel.getService('http.server'); + baseUrl = `http://127.0.0.1:${httpServer.getPort!()}`; + messaging = kernel.getService('notification'); + + // Three notifications per inbox: one WITH an `actionUrl`, two without — so + // the optional key is exercised in both states on the wire. + for (const user of [LIST_USER, MARK_USER, GAP_USER]) { + await messaging.emit({ topic: 'deal.won', audience: [user], payload: { title: 'Deal one', body: 'first', actionUrl: '/records/1' } }); + await messaging.emit({ topic: 'task.assigned', audience: [user], payload: { title: 'Task two', body: 'second' } }); + await messaging.emit({ topic: 'task.assigned', audience: [user], payload: { title: 'Task three', body: 'third' } }); + } + }, 60_000); + + afterAll(async () => { + if (kernel) { + await Promise.race([ + kernel.shutdown(), + new Promise((resolve) => setTimeout(resolve, 10_000)), + ]); + } + }, 30_000); + + /** Drive one route as `user`, asserting the shared envelope, and hand back `data`. */ + const getJson = async (user: string, path: string, init?: RequestInit) => { + const res = await fetch(`${baseUrl}${path}`, { + ...init, + headers: { 'x-test-user': user, 'content-type': 'application/json', ...(init?.headers ?? {}) }, + }); + expect(res.status, `${path} must answer 200`).toBe(200); + const body = await res.json(); + expect(envelopeViolations(body), `${path} is not the declared envelope: ${JSON.stringify(body)}`).toEqual([]); + return body.data; + }; + + describe('GET /api/v1/notifications', () => { + it('satisfies ListNotificationsResponseSchema (VALUE assertion)', async () => { + const data = await getJson(LIST_USER, '/api/v1/notifications'); + + const parsed = ListNotificationsResponseSchema.safeParse(data); + expect( + parsed.success ? [] : parsed.error!.issues.map((i) => `${i.path.join('.')}: ${i.code}`), + 'the wire body must satisfy ListNotificationsResponseSchema', + ).toEqual([]); + // Anti-vacuity: an empty list parses too. + expect(parsed.data?.notifications).toHaveLength(3); + expect(parsed.data?.unreadCount).toBe(3); + }); + + it('emits NO key the protocol does not declare, at the top level and one level down (KEY assertion)', async () => { + const data = await getJson(LIST_USER, '/api/v1/notifications'); + + expect( + Object.keys(data).filter((k) => !declaredListKeys().has(k)), + 'undeclared top-level keys on the wire body', + ).toEqual([]); + + const rows: Array> = data.notifications; + expect( + [...new Set(rows.flatMap((n) => Object.keys(n).filter((k) => !declaredNotificationKeys().has(k))))], + 'undeclared keys inside notifications[] on the wire body', + ).toEqual([]); + }); + + it('`createdAt` survives the driver round trip as a real ISO-8601 instant', async () => { + const data = await getJson(LIST_USER, '/api/v1/notifications'); + + for (const row of data.notifications as Array<{ id: string; createdAt: string }>) { + expect(typeof row.createdAt, `createdAt on ${row.id}`).toBe('string'); + // The refinement the in-memory fixture cannot prove: a SQL-flavoured + // `2026-01-01 00:00:00` is a string and would fail here. + expect(new Date(row.createdAt).toISOString(), `createdAt on ${row.id} is not ISO-8601`).toBe(row.createdAt); + expect(NotificationSchema.safeParse(row).success).toBe(true); + } + }); + + it('the JSON view of an absent `actionUrl` is a conforming body too', async () => { + const data = await getJson(LIST_USER, '/api/v1/notifications'); + const rows: Array> = data.notifications; + + const withUrl = rows.find((n) => n.title === 'Deal one')!; + const withoutUrl = rows.find((n) => n.title === 'Task two')!; + + expect(withUrl.actionUrl).toBe('/records/1'); + // In-process the key is present carrying `undefined` (pinned by the + // producer gate); `JSON.stringify` drops it here. Both views conform — + // `actionUrl` is declared `optional`, not `nullable`. + expect(Object.prototype.hasOwnProperty.call(withoutUrl, 'actionUrl')).toBe(false); + expect(NotificationSchema.safeParse(withoutUrl).success).toBe(true); + }); + }); + + describe('POST /api/v1/notifications/read and /read/all', () => { + it('both bodies satisfy their declared schemas and emit no undeclared key', async () => { + const ids: string[] = (await getJson(MARK_USER, '/api/v1/notifications')).notifications.map((n: any) => n.id); + + const readOne = await getJson(MARK_USER, '/api/v1/notifications/read', { + method: 'POST', + body: JSON.stringify({ ids: [ids[0]] }), + }); + const parsedOne = MarkNotificationsReadResponseSchema.safeParse(readOne); + expect( + parsedOne.success ? [] : parsedOne.error!.issues.map((i) => `${i.path.join('.')}: ${i.code}`), + 'POST /read wire body must satisfy MarkNotificationsReadResponseSchema', + ).toEqual([]); + expect(Object.keys(readOne).filter((k) => !declaredMarkReadKeys().has(k))).toEqual([]); + expect(parsedOne.data?.readCount).toBe(1); // anti-vacuity: a no-op also parses + + const readAll = await getJson(MARK_USER, '/api/v1/notifications/read/all', { method: 'POST' }); + const parsedAll = MarkAllNotificationsReadResponseSchema.safeParse(readAll); + expect( + parsedAll.success ? [] : parsedAll.error!.issues.map((i) => `${i.path.join('.')}: ${i.code}`), + 'POST /read/all wire body must satisfy MarkAllNotificationsReadResponseSchema', + ).toEqual([]); + expect(Object.keys(readAll).filter((k) => !declaredMarkAllReadKeys().has(k))).toEqual([]); + expect(parsedAll.data?.readCount).toBe(2); // the two still unread + }); + }); + + // ═══════════════════════════════════════════════════════════════════════════ + // Declared, not delivered — recorded, NOT endorsed + // ═══════════════════════════════════════════════════════════════════════════ + // + // The two assertions above are 3/3 green for this family. These two facts are + // real inconsistencies that BOTH assertions are structurally blind to, and + // that is the point worth writing down for #3877's Stage D ratchet: + // + // * `unreadCount` is a `number` whether it counts the total or the window, + // so a VALUE assertion cannot see a wrong semantic; + // * `cursor` is `optional`, so "no producer ever emits it" is a legal + // parse and the KEY assertion (⊆, not =) cannot see it either. + // + // Pinned as the measured behaviour of `origin/main`, with the issues that own + // the judgement call. Whichever way #6361 / #6363 are ruled, these two + // assertions are the ones that must flip — which is why they are here rather + // than left for the next reader to rediscover. + describe('[#6361 / #6363] the gaps the double assertion cannot see', () => { + it('[#6363] `unreadCount` counts the RETURNED WINDOW, not the total the schema describes', async () => { + const all = await getJson(GAP_USER, '/api/v1/notifications'); + expect(all.unreadCount, 'fixture must leave more than one unread for this to mean anything') + .toBeGreaterThan(1); + + const windowed = await getJson(GAP_USER, '/api/v1/notifications?limit=1'); + + expect(windowed.notifications).toHaveLength(1); + // Declared: 'Total number of unread notifications'. Delivered: the unread + // count within the fetched window. See #6363. + expect(windowed.unreadCount).toBe(1); + expect(windowed.unreadCount).not.toBe(all.unreadCount); + // …and it still parses, which is exactly the blind spot. + expect(ListNotificationsResponseSchema.safeParse(windowed).success).toBe(true); + }); + + it('[#6361 / #6363] `cursor` is declared on both sides and honoured on neither', async () => { + const page1 = await getJson(GAP_USER, '/api/v1/notifications?limit=2'); + const ids1 = page1.notifications.map((n: any) => n.id); + + // Response half (#6363): the declared `cursor` key is never emitted. + expect(Object.prototype.hasOwnProperty.call(page1, 'cursor')).toBe(false); + expect(declaredListKeys().has('cursor')).toBe(true); + + // Request half (#6361): sending the declared `cursor` returns the SAME + // page. An SDK caller paginating by the published contract loops forever. + const page2 = await getJson(GAP_USER, `/api/v1/notifications?limit=2&cursor=${encodeURIComponent(ids1[ids1.length - 1])}`); + expect(page2.notifications.map((n: any) => n.id)).toEqual(ids1); + + // Both pages conform — the whole reason this needed measuring by hand. + expect(ListNotificationsResponseSchema.safeParse(page1).success).toBe(true); + expect(ListNotificationsResponseSchema.safeParse(page2).success).toBe(true); + }); + }); +}); diff --git a/packages/runtime/src/notification-schema-conformance.test.ts b/packages/runtime/src/notification-schema-conformance.test.ts new file mode 100644 index 0000000000..7ded5e4b4f --- /dev/null +++ b/packages/runtime/src/notification-schema-conformance.test.ts @@ -0,0 +1,351 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// [#5792 / Part of #3877] Stage A, notification family — the DISPATCHER half. +// +// The producer gate lives next to the producer +// (`service-messaging/src/notification-schema-conformance.test.ts`); it proves +// `MessagingService` authors bodies that satisfy the schemas +// `DEFAULT_NOTIFICATION_ROUTES` declares. This file proves the OTHER half: that +// `/notifications` hands those bodies to the wire unchanged, and that every one +// of the domain's emit sites — not only the three happy ones — answers in a +// declared shape. +// +// ## Why a pass-through needs its own gate +// +// `domains/notifications.ts` forwards `deps.success(result)`. "Forwards" is a +// property of today's code, not of the contract: a domain that starts folding +// in a computed `cursor`, an `unreadTotal`, or a re-keyed row would emit keys +// no schema declares, and the producer-side gate could not see it. `safeParse` +// on its own could not see it either — a zod object strips unknown keys — which +// is why this file carries the SAME two assertions as the producer gate: +// +// * `Schema.safeParse(body.data)` judges VALUES; +// * `Object.keys(body.data) ⊆ Schema.shape` judges KEYS. +// +// Both allowed sets are derived from the schemas' own `shape` (#5682 / the +// maintainer's 2026-08-06 ruling point 3) — never hand-listed here. +// +// ## The eight emit sites of this family +// +// #3877 measured the notification family at EIGHT thin emit sites. They are the +// eight `return`s of `domains/notifications.ts`, and three of them are the ones +// carrying a `responseSchema`: +// +// 1 :79 capabilityUnavailable — no service / not serveable / no `listInbox` +// 2 :86 deps.error('Authentication required', 401) +// 3 :111 deps.success(listInbox result) ← ListNotificationsResponseSchema +// 4 :116 capabilityUnavailable — `markRead` absent +// 5 :128 throw validationFailure(...) — malformed mark-read body +// 6 :134 deps.success(markRead result) ← MarkNotificationsReadResponseSchema +// 7 :139 capabilityUnavailable — `markAllRead` absent +// 8 :141 deps.success(markAllRead result) ← MarkAllNotificationsReadResponseSchema +// +// (`return { handled: false }` for an unmatched sub-path is not an emit — the +// plugin's single exit turns it into 404 ROUTE_NOT_FOUND — but it is pinned +// below so the count is closed rather than assumed.) +// +// The five non-success sites carry no `responseSchema`; what they DO declare is +// the shared envelope, so they are gated against `envelopeViolations` plus the +// status each one owes. A family whose error exits were never checked is a +// family whose conformance claim covers only its happy path. + +import { describe, it, expect } from 'vitest'; +import { + BaseResponseSchema, + envelopeViolations, + ListNotificationsRequestSchema, + ListNotificationsResponseSchema, + MarkNotificationsReadResponseSchema, + MarkAllNotificationsReadResponseSchema, + NotificationSchema, + SERVICE_SELF_INFO_KEY, +} from '@objectstack/spec/api'; +import { serviceUnavailableMessage } from '@objectstack/spec/system'; +import { HttpDispatcher } from './http-dispatcher.js'; +import { validationFailureDetails } from './validation-failure.js'; + +const USER = 'usr_dispatch_conformance'; + +/** Declared key sets — derived from the schemas, never hand-listed. */ +const declaredListKeys = () => new Set(Object.keys((ListNotificationsResponseSchema as any).shape)); +const declaredNotificationKeys = () => new Set(Object.keys((NotificationSchema as any).shape)); +const declaredMarkReadKeys = () => new Set(Object.keys((MarkNotificationsReadResponseSchema as any).shape)); +const declaredMarkAllReadKeys = () => new Set(Object.keys((MarkAllNotificationsReadResponseSchema as any).shape)); + +/** + * The bodies the REAL `MessagingService` emits, measured by the producer gate + * in `service-messaging` and restated here as the double's returns. Restated + * rather than imported so `packages/runtime` does not take a dependency on the + * provider that happens to fill the slot today — the domain must forward ANY + * conforming provider's body, and `INotificationService` is what it is typed + * against. + */ +const LIST_BODY = { + notifications: [ + { + id: 'n1', type: 'deal.won', title: 'Deal one', body: 'first', + read: false, actionUrl: '/records/1', createdAt: '2026-08-07T15:02:26.891Z', + }, + { + id: 'n2', type: 'task.assigned', title: 'Task two', body: 'second', + read: true, actionUrl: undefined, createdAt: '2026-08-07T15:02:26.897Z', + }, + ], + unreadCount: 1, +}; +const MARK_READ_BODY = { success: true, readCount: 1 }; +const MARK_ALL_BODY = { success: true, readCount: 2 }; + +/** + * A dispatcher over a notification slot filled by `capabilities`. Omitting a + * method is how a real send-only provider presents (`listInbox` / `markRead` / + * `markAllRead` are separately OPTIONAL on `INotificationService`), which is + * what makes emit sites 1 / 4 / 7 reachable. + */ +function makeDispatcher(capabilities: Record | null = { + listInbox: async () => LIST_BODY, + markRead: async () => MARK_READ_BODY, + markAllRead: async () => MARK_ALL_BODY, +}) { + const resolve = (name: string) => (name === 'notification' ? capabilities : null); + const kernel: any = { + getService: resolve, + getServiceAsync: async (name: string) => resolve(name), + context: { getService: resolve }, + }; + return new HttpDispatcher(kernel); +} + +const CTX = { request: {}, executionContext: { userId: USER } } as any; +const ANON_CTX = { request: {} } as any; + +/** Every body this domain emits must satisfy the shared envelope in full. */ +function expectEnvelope(body: unknown, success: boolean) { + expect(BaseResponseSchema.safeParse(body).success, `not a BaseResponse: ${JSON.stringify(body)}`).toBe(true); + // `safeParse` alone passes a success body with no `data`, or a payload + // duplicated into a stray top-level key (#4049) — `envelopeViolations` is + // the declared envelope in full. + expect(envelopeViolations(body), `not the declared envelope: ${JSON.stringify(body)}`).toEqual([]); + expect((body as { success?: boolean }).success).toBe(success); +} + +describe('[#5792] /notifications conforms to the schemas the catalog declares', () => { + // ═══════════════════════════════════════════════════════════════════════ + // Emit site 3 — GET /notifications + // ═══════════════════════════════════════════════════════════════════════ + describe('emit site 3 — GET / → ListNotificationsResponseSchema', () => { + it('satisfies the declared schema (VALUE assertion)', async () => { + const result = await makeDispatcher().handleNotification('', 'GET', undefined, {}, CTX); + + expect(result.response?.status).toBe(200); + expectEnvelope(result.response?.body, true); + const parsed = ListNotificationsResponseSchema.safeParse(result.response?.body?.data); + expect( + parsed.success ? [] : parsed.error!.issues.map((i) => `${i.path.join('.')}: ${i.code}`), + 'GET /notifications body must satisfy ListNotificationsResponseSchema', + ).toEqual([]); + // Anti-vacuity: an empty list also parses. + expect(parsed.data?.notifications).toHaveLength(2); + }); + + it('emits NO top-level key the protocol does not declare (KEY assertion)', async () => { + const result = await makeDispatcher().handleNotification('', 'GET', undefined, {}, CTX); + + const declared = declaredListKeys(); + expect( + Object.keys(result.response?.body?.data ?? {}).filter((k) => !declared.has(k)), + 'undeclared top-level keys on the GET /notifications body', + ).toEqual([]); + }); + + it('emits NO `notifications[]` key the protocol does not declare (KEY assertion, one level down)', async () => { + const result = await makeDispatcher().handleNotification('', 'GET', undefined, {}, CTX); + + const declared = declaredNotificationKeys(); + const rows: Array> = result.response?.body?.data?.notifications ?? []; + expect(rows.length, 'fixture must produce rows for this gate to mean anything').toBe(2); + expect( + [...new Set(rows.flatMap((n) => Object.keys(n).filter((k) => !declared.has(k))))], + 'undeclared keys inside notifications[] on the GET /notifications body', + ).toEqual([]); + }); + + it('forwards the provider body UNCHANGED — no key added, none dropped', async () => { + // The pass-through property stated as an assertion rather than + // assumed from reading the handler. `deps.success(result)` must put + // the provider's object under `data` verbatim. + const result = await makeDispatcher().handleNotification('', 'GET', undefined, {}, CTX); + + expect(result.response?.body?.data).toBe(LIST_BODY); + expect(Object.keys(result.response?.body?.data)).toEqual(Object.keys(LIST_BODY)); + }); + + it('reads the declared query filters (`read` / `type` / `limit`) off the request', async () => { + // Anti-vacuity for the route itself: the three filters + // `ListNotificationsRequestSchema` declares AND `InboxQuery` accepts + // must reach the provider, or the conforming body above would be a + // conforming answer to a question nobody asked (#3676's shape). + const seen: unknown[] = []; + const dispatcher = makeDispatcher({ + listInbox: async (_userId: string, options: unknown) => { seen.push(options); return LIST_BODY; }, + markRead: async () => MARK_READ_BODY, + markAllRead: async () => MARK_ALL_BODY, + }); + + await dispatcher.handleNotification('', 'GET', undefined, { read: 'false', type: 'deal.won', limit: '7' }, CTX); + + expect(seen[0]).toEqual({ read: false, type: 'deal.won', limit: 7 }); + }); + + it('[#6361] recorded, not endorsed: the declared `limit` DEFAULT never reaches the provider', async () => { + // `ListNotificationsRequestSchema.limit` is `z.number().default(20)`, + // but this route never parses the query through that schema — with no + // `limit` the domain forwards `undefined` and the provider applies its + // own window (50). So the declared default has never been in effect. + // + // Pinned as the measured fact it is, NOT as the desired behaviour: + // #6361 is the judgement call (align the declaration to 50, or wire + // the query through the schema so 20 takes effect). Whichever way it + // is ruled, this assertion is the one that must change — which is the + // point of pinning it rather than leaving it for the next reader. + const seen: unknown[] = []; + const dispatcher = makeDispatcher({ + listInbox: async (_userId: string, options: unknown) => { seen.push(options); return LIST_BODY; }, + markRead: async () => MARK_READ_BODY, + markAllRead: async () => MARK_ALL_BODY, + }); + + await dispatcher.handleNotification('', 'GET', undefined, {}, CTX); + + expect(seen[0]).toEqual({ read: undefined, type: undefined, limit: undefined }); + expect((ListNotificationsRequestSchema as any).shape.limit._zod.def.defaultValue).toBe(20); + }); + }); + + // ═══════════════════════════════════════════════════════════════════════ + // Emit sites 6 and 8 — the two mark-read routes + // ═══════════════════════════════════════════════════════════════════════ + describe('emit site 6 — POST /read → MarkNotificationsReadResponseSchema', () => { + it('satisfies the declared schema (VALUE assertion)', async () => { + const result = await makeDispatcher().handleNotification('/read', 'POST', { ids: ['n1'] }, {}, CTX); + + expect(result.response?.status).toBe(200); + expectEnvelope(result.response?.body, true); + const parsed = MarkNotificationsReadResponseSchema.safeParse(result.response?.body?.data); + expect( + parsed.success ? [] : parsed.error!.issues.map((i) => `${i.path.join('.')}: ${i.code}`), + 'POST /notifications/read body must satisfy MarkNotificationsReadResponseSchema', + ).toEqual([]); + expect(parsed.data?.readCount).toBe(1); + }); + + it('emits NO key the protocol does not declare (KEY assertion)', async () => { + const result = await makeDispatcher().handleNotification('/read', 'POST', { ids: ['n1'] }, {}, CTX); + + const declared = declaredMarkReadKeys(); + expect( + Object.keys(result.response?.body?.data ?? {}).filter((k) => !declared.has(k)), + 'undeclared keys on the POST /notifications/read body', + ).toEqual([]); + }); + }); + + describe('emit site 8 — POST /read/all → MarkAllNotificationsReadResponseSchema', () => { + it('satisfies the declared schema (VALUE assertion)', async () => { + const result = await makeDispatcher().handleNotification('/read/all', 'POST', undefined, {}, CTX); + + expect(result.response?.status).toBe(200); + expectEnvelope(result.response?.body, true); + const parsed = MarkAllNotificationsReadResponseSchema.safeParse(result.response?.body?.data); + expect( + parsed.success ? [] : parsed.error!.issues.map((i) => `${i.path.join('.')}: ${i.code}`), + 'POST /notifications/read/all body must satisfy MarkAllNotificationsReadResponseSchema', + ).toEqual([]); + expect(parsed.data?.readCount).toBe(2); + }); + + it('emits NO key the protocol does not declare (KEY assertion)', async () => { + const result = await makeDispatcher().handleNotification('/read/all', 'POST', undefined, {}, CTX); + + const declared = declaredMarkAllReadKeys(); + expect( + Object.keys(result.response?.body?.data ?? {}).filter((k) => !declared.has(k)), + 'undeclared keys on the POST /notifications/read/all body', + ).toEqual([]); + }); + }); + + // ═══════════════════════════════════════════════════════════════════════ + // Emit sites 1 / 4 / 7 — the capability-unavailable exits + // ═══════════════════════════════════════════════════════════════════════ + // + // No `responseSchema` covers these; the envelope and the status ARE their + // contract. 501 (not 404, not 503) is the ADR-0076 D12 answer for "the + // route is mounted, the implementation is not" — see `domains/unavailable.ts`. + describe('emit sites 1 / 4 / 7 — capabilityUnavailable(…, "notification")', () => { + it.each([ + ['site 1 — empty slot', null, '', 'GET'], + // The marker key is taken from the spec's own constant, not spelled + // here — a hand-written `__serviceInfo` would make this row pass for + // the wrong reason the day the marker is renamed. + ['site 1 — a slot filled by a self-declared non-handler', { [SERVICE_SELF_INFO_KEY]: { status: 'stub', handlerReady: false }, listInbox: async () => LIST_BODY }, '', 'GET'], + ['site 1 — a send-only provider with no `listInbox`', { send: async () => ({ success: true }) }, '', 'GET'], + ['site 4 — an inbox provider with no `markRead`', { listInbox: async () => LIST_BODY }, '/read', 'POST'], + ['site 7 — an inbox provider with no `markAllRead`', { listInbox: async () => LIST_BODY }, '/read/all', 'POST'], + ])('%s → 501, declared envelope, the slot\'s own remedy sentence', async (_label, service, path, method) => { + const dispatcher = makeDispatcher(service as Record | null); + + const result = await dispatcher.handleNotification(path, method, { ids: ['n1'] }, {}, CTX); + + expect(result.handled).toBe(true); + expect(result.response?.status).toBe(501); + expectEnvelope(result.response?.body, false); + // The message is the SAME sentence discovery reports for this slot, + // by construction (`serviceUnavailableMessage`) — so the 501 body and + // the discovery entry cannot name different remedies. + expect(result.response?.body?.error?.message).toBe(serviceUnavailableMessage('notification')); + }); + }); + + // ═══════════════════════════════════════════════════════════════════════ + // Emit site 2 — the auth gate + // ═══════════════════════════════════════════════════════════════════════ + it('emit site 2 — an unauthenticated caller gets 401 in the declared envelope', async () => { + const result = await makeDispatcher().handleNotification('', 'GET', undefined, {}, ANON_CTX); + + expect(result.handled).toBe(true); + expect(result.response?.status).toBe(401); + expectEnvelope(result.response?.body, false); + expect(result.response?.body?.error?.message).toBe('Authentication required'); + }); + + // ═══════════════════════════════════════════════════════════════════════ + // Emit site 5 — the request-schema gate (#3899) + // ═══════════════════════════════════════════════════════════════════════ + it('emit site 5 — a malformed mark-read body is a VALIDATION_FAILED throw naming the field', async () => { + // `{"notificationIds": [...]}` used to become `markRead(userId, [])` — + // a 200 with `readCount: 0` and a badge that never cleared (#3899). + const dispatcher = makeDispatcher(); + + let thrown: unknown; + try { + await dispatcher.handleNotification('/read', 'POST', { notificationIds: ['n1'] }, {}, CTX); + } catch (e) { + thrown = e; + } + + const details = validationFailureDetails(thrown); + expect(details?.code).toBe('VALIDATION_FAILED'); + expect(details?.fields.map((f) => f.field)).toContain('ids'); + }); + + // ═══════════════════════════════════════════════════════════════════════ + // The ninth return: not an emit, and pinned so the count is closed + // ═══════════════════════════════════════════════════════════════════════ + it('an unmatched sub-path is `handled: false` — no body, so nothing to conform', async () => { + const result = await makeDispatcher().handleNotification('/unknown', 'GET', undefined, {}, CTX); + + expect(result.handled).toBe(false); + expect(result.response).toBeUndefined(); + }); +}); diff --git a/packages/services/service-messaging/package.json b/packages/services/service-messaging/package.json index b3d677d556..d93f61d14f 100644 --- a/packages/services/service-messaging/package.json +++ b/packages/services/service-messaging/package.json @@ -24,6 +24,7 @@ "@objectstack/spec": "workspace:*" }, "devDependencies": { + "@objectstack/metadata-core": "workspace:*", "@types/node": "^26.1.2", "typescript": "^6.0.3", "vitest": "^4.1.10" diff --git a/packages/services/service-messaging/src/notification-schema-conformance.test.ts b/packages/services/service-messaging/src/notification-schema-conformance.test.ts new file mode 100644 index 0000000000..2a63cf4d13 --- /dev/null +++ b/packages/services/service-messaging/src/notification-schema-conformance.test.ts @@ -0,0 +1,344 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// [#5792 / Part of #3877] Stage A, notification family — the PRODUCER half. +// +// `/api/v1/notifications` is served by the dispatcher's `/notifications` domain, +// which forwards `deps.success(result)` untouched. So the body on the wire is +// authored HERE: `MessagingService.listInbox / markRead / markAllRead` are the +// only things that decide its shape, and the catalog +// (`plugin-rest-api.zod.ts`, `DEFAULT_NOTIFICATION_ROUTES`) declares one +// `responseSchema` per route: +// +// GET /notifications → ListNotificationsResponseSchema → listInbox() +// POST /notifications/read → MarkNotificationsReadResponseSchema → markRead() +// POST /notifications/read/all → MarkAllNotificationsReadResponseSchema → markAllRead() +// +// Until this file nothing had ever put an emitted body and its declaring schema +// in the same assertion — every existing inbox test compares the result against +// a hand-written literal, which proves the code does what the test author +// believed, never what the contract declares (#3877's founding observation). +// +// ## The two assertions, and why one is not enough (#5682's lesson) +// +// Each route gets TWO deliberately different questions: +// +// * `Schema.safeParse()` judges VALUES — required keys present, `createdAt` +// a real ISO-8601 instant, `read` a boolean, `unreadCount` a number. +// * the key-set subset check judges KEYS — nothing emitted that the protocol +// never declared. `safeParse` is BLIND to this: a zod object strips unknown +// keys by default, so a producer that grows an extra key parses clean +// forever. That is exactly how `features` / `endpoints` survived on the +// discovery surface until #4828. +// +// The allowed key set is derived from the schema's own `shape` — never a +// hand-written array — so this gate cannot become a third dialect of the +// contract (#5682, and the maintainer's 2026-08-06 ruling point 3). +// +// ## Extended one level down, not recursed +// +// `notifications[]` rows are checked against `NotificationSchema` the same way. +// That is where this family's whole payload lives — a top-level-only gate would +// see two keys and nothing else, i.e. it would be nearly vacuous. Same call +// #5679 made for `routes`: extend to the level with a measured producer, do not +// recurse into open `z.record`s. +// +// ## Why the fixture is written by the REAL inbox channel +// +// `listInbox` maps stored rows, so a hand-written row fixture would let the test +// author invent the input as well as expect the output. The seed here is +// produced by driving the real single ingress (`emit()`) through the real +// `inbox` channel (`createInboxChannel`) into an in-memory engine, so only the +// STORAGE is a double. The row shape under test is the one `sys_inbox_message` +// actually receives in production. + +import { describe, it, expect } from 'vitest'; +import { assertEngineUpdateDispatch } from '@objectstack/metadata-core'; +import { + ListNotificationsResponseSchema, + MarkNotificationsReadResponseSchema, + MarkAllNotificationsReadResponseSchema, + NotificationSchema, +} from '@objectstack/spec/api'; +import { MessagingService } from './messaging-service.js'; +import { createInboxChannel } from './inbox-channel.js'; + +const USER = 'usr_conformance'; + +/** Declared top-level keys of the list response — derived, never hand-listed. */ +function declaredListKeys(): Set { + return new Set(Object.keys((ListNotificationsResponseSchema as any).shape)); +} + +/** Declared keys of ONE inbox row (`notifications[]`) — the level down. */ +function declaredNotificationKeys(): Set { + return new Set(Object.keys((NotificationSchema as any).shape)); +} + +/** Declared keys of the mark-read responses (both routes share the shape). */ +function declaredMarkReadKeys(): Set { + return new Set(Object.keys((MarkNotificationsReadResponseSchema as any).shape)); +} + +function declaredMarkAllReadKeys(): Set { + return new Set(Object.keys((MarkAllNotificationsReadResponseSchema as any).shape)); +} + +const silentLogger = () => ({ + debug() {}, info() {}, warn() {}, error() {}, +}) as any; + +/** + * In-memory stand-in for the data engine, supporting the flat-equality `where` + * filters the inbox read API issues plus the receipt upsert. The STORAGE is the + * only fake here. + * + * `update` opens with `assertEngineUpdateDispatch(data, options)` — the + * PRODUCER's own dispatch predicate (`@objectstack/metadata-core`), not a + * hand-mirrored `if`. `check:engine-double-contract` requires it, and the + * reason is this file's whole subject one layer down: a double looser than the + * real engine accepts calls a real server refuses, and the suite stays green + * over a route that is dead in production (#4434 / #5197). Declared members are + * exactly the ones the inbox read path calls — a fake verb nothing exercises is + * a second contract nobody checks. + */ +function inboxEngine() { + const store: Record = {}; + let seq = 0; + const matches = (row: any, where: any = {}) => + Object.entries(where).every(([k, v]) => String(row[k]) === String(v)); + return { + store, + async find(object: string, query: any = {}) { + let rows = (store[object] ?? []).filter((r) => matches(r, query.where)); + const ob = Array.isArray(query.orderBy) ? query.orderBy : []; + if (ob.some((o: any) => o.field === 'created_at' && o.order === 'desc')) { + rows = [...rows].sort((a, b) => String(b.created_at).localeCompare(String(a.created_at))); + } + return typeof query.limit === 'number' ? rows.slice(0, query.limit) : rows; + }, + async findOne(object: string, query: any = {}) { + return (store[object] ?? []).find((r) => matches(r, query.where)) ?? null; + }, + async insert(object: string, row: any) { + const created = { id: `row_${++seq}`, ...row }; + (store[object] ??= []).push(created); + return created; + }, + async update(object: string, data: any, options: any = {}) { + assertEngineUpdateDispatch(data, options); + for (const r of store[object] ?? []) { + if (matches(r, options.where)) Object.assign(r, data); + } + return {}; + }, + } as any; +} + +/** + * A service whose inbox rows were written by the REAL `inbox` channel through + * the REAL `emit()` ingress. Two notifications: one carrying an `actionUrl`, + * one without — so the optional key is exercised in both states. + */ +async function seededService() { + const engine = inboxEngine(); + const service = new MessagingService({ logger: silentLogger(), getData: () => engine }); + service.registerChannel(createInboxChannel({ getData: () => engine })); + + await service.emit({ + topic: 'deal.won', + audience: [USER], + payload: { title: 'Deal one', body: 'first', actionUrl: '/records/1' }, + }); + await service.emit({ + topic: 'task.assigned', + audience: [USER], + payload: { title: 'Task two', body: 'second' }, + }); + + return { service, engine }; +} + +describe('[#5792] MessagingService conforms to the schemas the catalog declares', () => { + describe('GET /notifications → listInbox() / ListNotificationsResponseSchema', () => { + it('satisfies the declared schema (VALUE assertion)', async () => { + const { service } = await seededService(); + + const body = await service.listInbox(USER); + + const result = ListNotificationsResponseSchema.safeParse(body); + expect( + result.success ? [] : result.error.issues.map((i) => `${i.path.join('.')}: ${i.code}`), + 'listInbox() must satisfy ListNotificationsResponseSchema', + ).toEqual([]); + }); + + it('emits NO top-level key the protocol does not declare (KEY assertion)', async () => { + const { service } = await seededService(); + + const body = await service.listInbox(USER); + + const declared = declaredListKeys(); + const undeclared = Object.keys(body).filter((k) => !declared.has(k)); + expect(undeclared, 'undeclared top-level keys on the listInbox() body').toEqual([]); + }); + + it('emits NO `notifications[]` key the protocol does not declare (KEY assertion, one level down)', async () => { + const { service } = await seededService(); + + const body = await service.listInbox(USER); + + const declared = declaredNotificationKeys(); + const undeclared = [ + ...new Set(body.notifications.flatMap((n) => Object.keys(n).filter((k) => !declared.has(k)))), + ]; + expect(undeclared, 'undeclared keys inside notifications[] on the listInbox() body').toEqual([]); + }); + + it('anti-vacuity: the fixture really produces rows, so neither assertion passes on an empty list', async () => { + const { service } = await seededService(); + + const body = await service.listInbox(USER); + + // Without this, both gates above are satisfied by `{notifications: [], unreadCount: 0}` + // — the empty-verdict green #5046 warns about. + expect(body.notifications).toHaveLength(2); + expect(body.unreadCount).toBe(2); + expect(body.notifications.every((n) => NotificationSchema.safeParse(n).success)).toBe(true); + }); + + it('every row carries the REQUIRED keys, with `createdAt` a real ISO-8601 instant', async () => { + const { service } = await seededService(); + + const body = await service.listInbox(USER); + + for (const row of body.notifications) { + // `NotificationSchema.createdAt` is `z.string().datetime()`, which is + // stricter than "a string": a SQL-flavoured `2026-01-01 00:00:00` + // would parse as a string and fail the datetime refinement. This is + // the one value in the family a driver could plausibly hand back in + // the wrong dialect, so it is asserted as a value, not a type. + expect(typeof row.createdAt, `createdAt on ${row.id}`).toBe('string'); + expect( + NotificationSchema.safeParse(row).success, + `row ${row.id} does not satisfy NotificationSchema: ${JSON.stringify(row)}`, + ).toBe(true); + expect(new Date(row.createdAt).toISOString()).toBe(row.createdAt); + } + }); + + it('`actionUrl` is a DECLARED optional — the key is present either way, value `undefined` when absent', async () => { + const { service } = await seededService(); + + const body = await service.listInbox(USER); + const withUrl = body.notifications.find((n) => n.title === 'Deal one')!; + const withoutUrl = body.notifications.find((n) => n.title === 'Task two')!; + + expect(withUrl.actionUrl).toBe('/records/1'); + // The mapper writes `actionUrl: … ?? undefined` unconditionally, so + // `Object.keys()` sees the key on BOTH rows while `JSON.stringify` + // drops it from the one that is empty. The key gate above reads + // `Object.keys()`, i.e. the stricter of the two views — the same + // `routes.mcp` nuance #5679 measured on the discovery producer. + expect(Object.prototype.hasOwnProperty.call(withoutUrl, 'actionUrl')).toBe(true); + expect(withoutUrl.actionUrl).toBeUndefined(); + expect(declaredNotificationKeys().has('actionUrl')).toBe(true); + }); + }); + + describe('POST /notifications/read → markRead() / MarkNotificationsReadResponseSchema', () => { + it('satisfies the declared schema (VALUE assertion)', async () => { + const { service } = await seededService(); + const ids = (await service.listInbox(USER)).notifications.map((n) => n.id); + + const body = await service.markRead(USER, [ids[0]]); + + const result = MarkNotificationsReadResponseSchema.safeParse(body); + expect( + result.success ? [] : result.error.issues.map((i) => `${i.path.join('.')}: ${i.code}`), + 'markRead() must satisfy MarkNotificationsReadResponseSchema', + ).toEqual([]); + // Anti-vacuity: a no-op `{success:true, readCount:0}` also parses, so + // pin that this call really transitioned a notification. + expect(body.readCount).toBe(1); + }); + + it('emits NO key the protocol does not declare (KEY assertion)', async () => { + const { service } = await seededService(); + const ids = (await service.listInbox(USER)).notifications.map((n) => n.id); + + const body = await service.markRead(USER, [ids[0]]); + + const declared = declaredMarkReadKeys(); + expect( + Object.keys(body).filter((k) => !declared.has(k)), + 'undeclared keys on the markRead() body', + ).toEqual([]); + }); + + it('the degraded exit (no data engine) is a CONFORMING body too', async () => { + // The early return `{ success: true, readCount: 0 }` is its own emit + // site; a shape that only conforms on the happy path is not a + // conforming route. + const noData = new MessagingService({ logger: silentLogger() }); + + const body = await noData.markRead(USER, ['n1']); + + expect(MarkNotificationsReadResponseSchema.safeParse(body).success).toBe(true); + expect(Object.keys(body).filter((k) => !declaredMarkReadKeys().has(k))).toEqual([]); + }); + }); + + describe('POST /notifications/read/all → markAllRead() / MarkAllNotificationsReadResponseSchema', () => { + it('satisfies the declared schema (VALUE assertion)', async () => { + const { service } = await seededService(); + + const body = await service.markAllRead(USER); + + const result = MarkAllNotificationsReadResponseSchema.safeParse(body); + expect( + result.success ? [] : result.error.issues.map((i) => `${i.path.join('.')}: ${i.code}`), + 'markAllRead() must satisfy MarkAllNotificationsReadResponseSchema', + ).toEqual([]); + expect(body.readCount).toBe(2); + }); + + it('emits NO key the protocol does not declare (KEY assertion)', async () => { + const { service } = await seededService(); + + const body = await service.markAllRead(USER); + + const declared = declaredMarkAllReadKeys(); + expect( + Object.keys(body).filter((k) => !declared.has(k)), + 'undeclared keys on the markAllRead() body', + ).toEqual([]); + }); + + it('the empty-inbox exit is a CONFORMING body too', async () => { + const { service } = await seededService(); + await service.markAllRead(USER); + + const body = await service.markAllRead(USER); // nothing left unread + + expect(MarkAllNotificationsReadResponseSchema.safeParse(body).success).toBe(true); + expect(body.readCount).toBe(0); + }); + }); + + // ═══════════════════════════════════════════════════════════════════════ + // The two mark-read routes declare SEPARATE schemas for one shape + // ═══════════════════════════════════════════════════════════════════════ + // + // #5682 added an equivalence pin because discovery had a lenient response + // schema beside a strict producer schema, and either could grow a key alone. + // This family has no lenient/strict pair — the catalog's `responseSchema` + // IS the producer contract, and no consumer parses a second, looser copy + // (`client.notifications.*` types its returns with `z.infer` of these very + // schemas and performs no runtime parse). What it DOES have is the other + // shape of the same risk: two separately-declared schemas that must stay + // identical because ONE producer method (`markAllRead` delegates to + // `markRead`) emits both bodies. Pinned for the same reason. + it('MarkNotificationsRead and MarkAllNotificationsRead declare the SAME key set', () => { + expect(declaredMarkAllReadKeys()).toEqual(declaredMarkReadKeys()); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 17895a4298..520eadf03b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2245,6 +2245,9 @@ importers: specifier: workspace:* version: link:../../spec devDependencies: + '@objectstack/metadata-core': + specifier: workspace:* + version: link:../../metadata-core '@types/node': specifier: ^26.1.2 version: 26.1.2