Skip to content

Commit 466bd92

Browse files
qq9340100claude
andauthored
test(runtime,service-messaging): notification 族响应体一致性 —— 双断言口径,8 个 emit 站点逐条覆盖 (#5792) (#6379)
Part of #3877(Stage A 逐族推进,notification 为本族),照 PR #5682(discovery 族) 的成套做法:每条声明了 responseSchema 的路由上两条刻意不同的断言 —— schema.parse() 判值,发出键集 ⊆ 声明键集判键(单一 parse 对「多发未声明键」永远绿, 因为 zod object 默认 strip 未知键)。允许集一律从 schema 的 shape 推导,不手写数组。 三个 gate,按 producer 分文件(#5682 的形状): - service-messaging:REAL MessagingService,fixture 由 REAL inbox channel 经 emit() 写入,只有存储是 double; - runtime(单元):REAL 域处理器 handleNotification,覆盖全部 8 个 emit 站点 —— 3 个 success 走双断言,5 个非 success(501×3 / 401 / VALIDATION_FAILED)走声明的 信封 + 各自的状态码; - runtime(集成):真实 boot(sqlite-wasm + ObjectQL + messaging + hono + dispatcher), 校验浏览器真正收到的 JSON —— createdAt 经真实驱动往返仍是 ISO-8601, actionUrl 的 JSON 视图与对象视图都合规。 违规率:双断言 **3/3 全绿**,两个方向都没有发现不一致。测量中另发现两条真实的 declared-vs-delivered 缺口,两条断言结构上都看不见,已按判断题另立单 (#6361 请求侧 cursor/limit,#6363 响应侧 unreadCount/cursor),本 PR 只如实钉住现状。 service-messaging 新增 @objectstack/metadata-core devDependency: check:engine-double-contract 要求 fake engine 的 update() 用生产者自己的 assertEngineUpdateDispatch 开场,而该包此前没有这条依赖(五个兄弟 service 包已有先例)。 Claude-Session: https://claude.ai/code/session_01Wbxm29qPKnLf44AbSxizqW Co-authored-by: Claude <noreply@anthropic.com>
1 parent 35f7fb4 commit 466bd92

5 files changed

Lines changed: 969 additions & 0 deletions

File tree

Lines changed: 270 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,270 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// [#5792 / Part of #3877] Stage A, notification family — the WIRE half.
4+
//
5+
// The other two gates check the two halves in isolation: the producer
6+
// (`service-messaging/src/notification-schema-conformance.test.ts`) and the
7+
// dispatcher domain (`./notification-schema-conformance.test.ts`). This one
8+
// checks the composed shape a browser actually receives, over a real socket,
9+
// through a real SQL driver — the same call #5682 made for the REST discovery
10+
// gate, and for the same reason: neither producer alone is the thing a client
11+
// parses.
12+
//
13+
// Two facts are only measurable here:
14+
//
15+
// 1. `createdAt` is `z.string().datetime()`, i.e. an ISO-8601 instant and not
16+
// merely "a string". The value makes a full round trip through
17+
// `sys_inbox_message.created_at` (`Field.datetime()`) and back out of the
18+
// driver. A driver dialect (`2026-01-01 00:00:00`, or a `Date` object)
19+
// would satisfy the in-memory producer gate's fixture and fail here.
20+
// 2. `actionUrl` is written unconditionally as `… ?? undefined`, so
21+
// `Object.keys()` sees the key on every row while `JSON.stringify` drops
22+
// it from the empty ones. The producer gate reads the object view; only
23+
// this file reads the JSON view. Both must conform — the `routes.mcp`
24+
// nuance #5679 measured, in this family.
25+
//
26+
// The boot mirrors `notifications.hono.integration.test.ts` (the #3362
27+
// regression), deliberately: that suite proved the routes are REACHABLE, this
28+
// one proves what comes back is what the catalog declares. Kept as a separate
29+
// file rather than bolted onto it so the conformance gate can be read, moved
30+
// or ratcheted (#3877 Stage D) without dragging a reachability regression with
31+
// it.
32+
33+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
34+
import { ObjectKernel, Plugin, PluginContext } from '@objectstack/core';
35+
import { HonoServerPlugin } from '@objectstack/plugin-hono-server';
36+
import { ObjectQLPlugin } from '@objectstack/objectql';
37+
import { SqliteWasmDriver } from '@objectstack/driver-sqlite-wasm';
38+
import { MessagingServicePlugin, MessagingService } from '@objectstack/service-messaging';
39+
import {
40+
envelopeViolations,
41+
ListNotificationsResponseSchema,
42+
MarkNotificationsReadResponseSchema,
43+
MarkAllNotificationsReadResponseSchema,
44+
NotificationSchema,
45+
} from '@objectstack/spec/api';
46+
import type { IHttpServer } from '@objectstack/spec/contracts';
47+
48+
import { createDispatcherPlugin } from './dispatcher-plugin.js';
49+
import { DriverPlugin } from './driver-plugin.js';
50+
51+
// One inbox per concern. The mark-read routes MUTATE read-state, so a shared
52+
// user would make these suites order-dependent — the unread fixture the gap
53+
// suite needs would be consumed by whichever mark-read test ran first.
54+
const LIST_USER = 'usr_notif_conformance_list';
55+
const MARK_USER = 'usr_notif_conformance_mark';
56+
const GAP_USER = 'usr_notif_conformance_gap';
57+
58+
/** Declared key sets — derived from the schemas, never hand-listed. */
59+
const declaredListKeys = () => new Set(Object.keys((ListNotificationsResponseSchema as any).shape));
60+
const declaredNotificationKeys = () => new Set(Object.keys((NotificationSchema as any).shape));
61+
const declaredMarkReadKeys = () => new Set(Object.keys((MarkNotificationsReadResponseSchema as any).shape));
62+
const declaredMarkAllReadKeys = () => new Set(Object.keys((MarkAllNotificationsReadResponseSchema as any).shape));
63+
64+
/** Minimal `auth` service — `x-test-user` names the principal, absent = anonymous. */
65+
function fakeAuthPlugin(): Plugin {
66+
return {
67+
name: 'com.objectstack.test.fake-auth-notif-conformance',
68+
version: '1.0.0',
69+
init: async (ctx: PluginContext) => {
70+
ctx.registerService('auth', {
71+
api: {
72+
getSession: async ({ headers }: { headers: any }) => {
73+
const uid = typeof headers?.get === 'function'
74+
? headers.get('x-test-user')
75+
: headers?.['x-test-user'];
76+
return uid ? { user: { id: uid } } : undefined;
77+
},
78+
},
79+
});
80+
},
81+
};
82+
}
83+
84+
describe('[#5792] the notification wire bodies conform to the schemas the catalog declares', () => {
85+
let kernel: ObjectKernel;
86+
let baseUrl: string;
87+
let messaging: MessagingService;
88+
89+
beforeAll(async () => {
90+
kernel = new ObjectKernel({ logLevel: 'silent' });
91+
await kernel.use(new DriverPlugin(new SqliteWasmDriver({ filename: ':memory:' })));
92+
await kernel.use(new ObjectQLPlugin());
93+
// Inline delivery so `emit()` materializes the inbox row synchronously.
94+
await kernel.use(new MessagingServicePlugin({ reliableDelivery: false }));
95+
await kernel.use(fakeAuthPlugin());
96+
await kernel.use(new HonoServerPlugin({ port: 0 }));
97+
await kernel.use(createDispatcherPlugin({ prefix: '/api/v1', securityHeaders: false, requireAuth: false }));
98+
await kernel.bootstrap();
99+
100+
const httpServer = kernel.getService<IHttpServer>('http.server');
101+
baseUrl = `http://127.0.0.1:${httpServer.getPort!()}`;
102+
messaging = kernel.getService<MessagingService>('notification');
103+
104+
// Three notifications per inbox: one WITH an `actionUrl`, two without — so
105+
// the optional key is exercised in both states on the wire.
106+
for (const user of [LIST_USER, MARK_USER, GAP_USER]) {
107+
await messaging.emit({ topic: 'deal.won', audience: [user], payload: { title: 'Deal one', body: 'first', actionUrl: '/records/1' } });
108+
await messaging.emit({ topic: 'task.assigned', audience: [user], payload: { title: 'Task two', body: 'second' } });
109+
await messaging.emit({ topic: 'task.assigned', audience: [user], payload: { title: 'Task three', body: 'third' } });
110+
}
111+
}, 60_000);
112+
113+
afterAll(async () => {
114+
if (kernel) {
115+
await Promise.race([
116+
kernel.shutdown(),
117+
new Promise<void>((resolve) => setTimeout(resolve, 10_000)),
118+
]);
119+
}
120+
}, 30_000);
121+
122+
/** Drive one route as `user`, asserting the shared envelope, and hand back `data`. */
123+
const getJson = async (user: string, path: string, init?: RequestInit) => {
124+
const res = await fetch(`${baseUrl}${path}`, {
125+
...init,
126+
headers: { 'x-test-user': user, 'content-type': 'application/json', ...(init?.headers ?? {}) },
127+
});
128+
expect(res.status, `${path} must answer 200`).toBe(200);
129+
const body = await res.json();
130+
expect(envelopeViolations(body), `${path} is not the declared envelope: ${JSON.stringify(body)}`).toEqual([]);
131+
return body.data;
132+
};
133+
134+
describe('GET /api/v1/notifications', () => {
135+
it('satisfies ListNotificationsResponseSchema (VALUE assertion)', async () => {
136+
const data = await getJson(LIST_USER, '/api/v1/notifications');
137+
138+
const parsed = ListNotificationsResponseSchema.safeParse(data);
139+
expect(
140+
parsed.success ? [] : parsed.error!.issues.map((i) => `${i.path.join('.')}: ${i.code}`),
141+
'the wire body must satisfy ListNotificationsResponseSchema',
142+
).toEqual([]);
143+
// Anti-vacuity: an empty list parses too.
144+
expect(parsed.data?.notifications).toHaveLength(3);
145+
expect(parsed.data?.unreadCount).toBe(3);
146+
});
147+
148+
it('emits NO key the protocol does not declare, at the top level and one level down (KEY assertion)', async () => {
149+
const data = await getJson(LIST_USER, '/api/v1/notifications');
150+
151+
expect(
152+
Object.keys(data).filter((k) => !declaredListKeys().has(k)),
153+
'undeclared top-level keys on the wire body',
154+
).toEqual([]);
155+
156+
const rows: Array<Record<string, unknown>> = data.notifications;
157+
expect(
158+
[...new Set(rows.flatMap((n) => Object.keys(n).filter((k) => !declaredNotificationKeys().has(k))))],
159+
'undeclared keys inside notifications[] on the wire body',
160+
).toEqual([]);
161+
});
162+
163+
it('`createdAt` survives the driver round trip as a real ISO-8601 instant', async () => {
164+
const data = await getJson(LIST_USER, '/api/v1/notifications');
165+
166+
for (const row of data.notifications as Array<{ id: string; createdAt: string }>) {
167+
expect(typeof row.createdAt, `createdAt on ${row.id}`).toBe('string');
168+
// The refinement the in-memory fixture cannot prove: a SQL-flavoured
169+
// `2026-01-01 00:00:00` is a string and would fail here.
170+
expect(new Date(row.createdAt).toISOString(), `createdAt on ${row.id} is not ISO-8601`).toBe(row.createdAt);
171+
expect(NotificationSchema.safeParse(row).success).toBe(true);
172+
}
173+
});
174+
175+
it('the JSON view of an absent `actionUrl` is a conforming body too', async () => {
176+
const data = await getJson(LIST_USER, '/api/v1/notifications');
177+
const rows: Array<Record<string, unknown>> = data.notifications;
178+
179+
const withUrl = rows.find((n) => n.title === 'Deal one')!;
180+
const withoutUrl = rows.find((n) => n.title === 'Task two')!;
181+
182+
expect(withUrl.actionUrl).toBe('/records/1');
183+
// In-process the key is present carrying `undefined` (pinned by the
184+
// producer gate); `JSON.stringify` drops it here. Both views conform —
185+
// `actionUrl` is declared `optional`, not `nullable`.
186+
expect(Object.prototype.hasOwnProperty.call(withoutUrl, 'actionUrl')).toBe(false);
187+
expect(NotificationSchema.safeParse(withoutUrl).success).toBe(true);
188+
});
189+
});
190+
191+
describe('POST /api/v1/notifications/read and /read/all', () => {
192+
it('both bodies satisfy their declared schemas and emit no undeclared key', async () => {
193+
const ids: string[] = (await getJson(MARK_USER, '/api/v1/notifications')).notifications.map((n: any) => n.id);
194+
195+
const readOne = await getJson(MARK_USER, '/api/v1/notifications/read', {
196+
method: 'POST',
197+
body: JSON.stringify({ ids: [ids[0]] }),
198+
});
199+
const parsedOne = MarkNotificationsReadResponseSchema.safeParse(readOne);
200+
expect(
201+
parsedOne.success ? [] : parsedOne.error!.issues.map((i) => `${i.path.join('.')}: ${i.code}`),
202+
'POST /read wire body must satisfy MarkNotificationsReadResponseSchema',
203+
).toEqual([]);
204+
expect(Object.keys(readOne).filter((k) => !declaredMarkReadKeys().has(k))).toEqual([]);
205+
expect(parsedOne.data?.readCount).toBe(1); // anti-vacuity: a no-op also parses
206+
207+
const readAll = await getJson(MARK_USER, '/api/v1/notifications/read/all', { method: 'POST' });
208+
const parsedAll = MarkAllNotificationsReadResponseSchema.safeParse(readAll);
209+
expect(
210+
parsedAll.success ? [] : parsedAll.error!.issues.map((i) => `${i.path.join('.')}: ${i.code}`),
211+
'POST /read/all wire body must satisfy MarkAllNotificationsReadResponseSchema',
212+
).toEqual([]);
213+
expect(Object.keys(readAll).filter((k) => !declaredMarkAllReadKeys().has(k))).toEqual([]);
214+
expect(parsedAll.data?.readCount).toBe(2); // the two still unread
215+
});
216+
});
217+
218+
// ═══════════════════════════════════════════════════════════════════════════
219+
// Declared, not delivered — recorded, NOT endorsed
220+
// ═══════════════════════════════════════════════════════════════════════════
221+
//
222+
// The two assertions above are 3/3 green for this family. These two facts are
223+
// real inconsistencies that BOTH assertions are structurally blind to, and
224+
// that is the point worth writing down for #3877's Stage D ratchet:
225+
//
226+
// * `unreadCount` is a `number` whether it counts the total or the window,
227+
// so a VALUE assertion cannot see a wrong semantic;
228+
// * `cursor` is `optional`, so "no producer ever emits it" is a legal
229+
// parse and the KEY assertion (⊆, not =) cannot see it either.
230+
//
231+
// Pinned as the measured behaviour of `origin/main`, with the issues that own
232+
// the judgement call. Whichever way #6361 / #6363 are ruled, these two
233+
// assertions are the ones that must flip — which is why they are here rather
234+
// than left for the next reader to rediscover.
235+
describe('[#6361 / #6363] the gaps the double assertion cannot see', () => {
236+
it('[#6363] `unreadCount` counts the RETURNED WINDOW, not the total the schema describes', async () => {
237+
const all = await getJson(GAP_USER, '/api/v1/notifications');
238+
expect(all.unreadCount, 'fixture must leave more than one unread for this to mean anything')
239+
.toBeGreaterThan(1);
240+
241+
const windowed = await getJson(GAP_USER, '/api/v1/notifications?limit=1');
242+
243+
expect(windowed.notifications).toHaveLength(1);
244+
// Declared: 'Total number of unread notifications'. Delivered: the unread
245+
// count within the fetched window. See #6363.
246+
expect(windowed.unreadCount).toBe(1);
247+
expect(windowed.unreadCount).not.toBe(all.unreadCount);
248+
// …and it still parses, which is exactly the blind spot.
249+
expect(ListNotificationsResponseSchema.safeParse(windowed).success).toBe(true);
250+
});
251+
252+
it('[#6361 / #6363] `cursor` is declared on both sides and honoured on neither', async () => {
253+
const page1 = await getJson(GAP_USER, '/api/v1/notifications?limit=2');
254+
const ids1 = page1.notifications.map((n: any) => n.id);
255+
256+
// Response half (#6363): the declared `cursor` key is never emitted.
257+
expect(Object.prototype.hasOwnProperty.call(page1, 'cursor')).toBe(false);
258+
expect(declaredListKeys().has('cursor')).toBe(true);
259+
260+
// Request half (#6361): sending the declared `cursor` returns the SAME
261+
// page. An SDK caller paginating by the published contract loops forever.
262+
const page2 = await getJson(GAP_USER, `/api/v1/notifications?limit=2&cursor=${encodeURIComponent(ids1[ids1.length - 1])}`);
263+
expect(page2.notifications.map((n: any) => n.id)).toEqual(ids1);
264+
265+
// Both pages conform — the whole reason this needed measuring by hand.
266+
expect(ListNotificationsResponseSchema.safeParse(page1).success).toBe(true);
267+
expect(ListNotificationsResponseSchema.safeParse(page2).success).toBe(true);
268+
});
269+
});
270+
});

0 commit comments

Comments
 (0)