Skip to content

Commit 50cfb3d

Browse files
authored
test(runtime): e2e regression for notifications mark-read on the hono server (#3362) (#3388)
Adds a real end-to-end regression test that boots the hono server + dispatcher + messaging service and drives the in-app notifications mark-read flow over HTTP, asserting sys_notification_receipt rows flip to read and the unread count drops. Test-only (plus a test-scoped devDependency); no runtime change. Verified #3362 does not reproduce on current main.
1 parent 818e6a3 commit 50cfb3d

4 files changed

Lines changed: 176 additions & 0 deletions

File tree

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
---
2+
---
3+
4+
Add an end-to-end regression test that boots a real hono server (ObjectQL + messaging + dispatcher) and drives the in-app notifications mark-read flow over HTTP, asserting `sys_notification_receipt` rows flip to `read` and the unread count drops (#3362). Test-only (plus a test-scoped devDependency); no runtime code changes, so this releases nothing.

packages/runtime/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@
4545
"devDependencies": {
4646
"@objectstack/plugin-hono-server": "workspace:*",
4747
"@objectstack/service-datasource": "workspace:*",
48+
"@objectstack/service-messaging": "workspace:*",
4849
"typescript": "^6.0.3",
4950
"vitest": "^4.1.10"
5051
},
Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
4+
import { ObjectKernel, Plugin, PluginContext } from '@objectstack/core';
5+
import { HonoServerPlugin } from '@objectstack/plugin-hono-server';
6+
import { ObjectQLPlugin } from '@objectstack/objectql';
7+
import { InMemoryDriver } from '@objectstack/driver-memory';
8+
import { MessagingServicePlugin, MessagingService } from '@objectstack/service-messaging';
9+
10+
import { createDispatcherPlugin } from './dispatcher-plugin.js';
11+
import { DriverPlugin } from './driver-plugin.js';
12+
13+
/**
14+
* End-to-end regression for framework #3362 (`#3354 not effective on hono`).
15+
*
16+
* The in-app notifications surface (ADR-0030) — `GET /api/v1/notifications`,
17+
* `POST /api/v1/notifications/read[/all]` — is mounted by the dispatcher plugin
18+
* (`createDispatcherPlugin`) on the shared `IHttpServer`. #3362 claimed those
19+
* mounts never reached the standalone `os dev` / `os serve` hono listener, so
20+
* mark-read 404'd and the unread badge never cleared. It does NOT reproduce on
21+
* a real hono boot: the dispatcher's `server.<verb>()` registrations DO land on
22+
* the hono app (proven by `dispatcher-plugin.ready.integration.test.ts` for
23+
* `/ready`), the `notification` service resolves (backed by the messaging
24+
* service), and mark-read persists.
25+
*
26+
* The pre-existing coverage only ever asserted route *registration* on a FAKE
27+
* server (`dispatcher-plugin.routes.test.ts`) — it could not catch a real
28+
* unmounting on hono, nor a break in service resolution or receipt persistence.
29+
* This test closes that gap: it boots the actual HTTP stack (ObjectQL +
30+
* messaging + hono + dispatcher), opens a real socket, delivers notifications,
31+
* and drives mark-read over `fetch` exactly like the Console bell, asserting the
32+
* `sys_notification_receipt` rows flip to `read` and the unread count drops.
33+
*/
34+
35+
const TEST_USER = 'usr_notif_e2e';
36+
37+
/**
38+
* Minimal `auth` service so `resolveExecutionContext` can resolve an
39+
* authenticated principal from the request. `getSession` returns the test user
40+
* when the `x-test-user` header is present, else anonymous — enough to exercise
41+
* both the authed (200) and self-gated (401) paths without a full better-auth
42+
* stack. Mirrors the `{ api: { getSession } }` shape the resolver reads.
43+
*/
44+
function fakeAuthPlugin(): Plugin {
45+
return {
46+
name: 'com.objectstack.test.fake-auth',
47+
version: '1.0.0',
48+
init: async (ctx: PluginContext) => {
49+
ctx.registerService('auth', {
50+
api: {
51+
getSession: async ({ headers }: { headers: any }) => {
52+
const uid = typeof headers?.get === 'function'
53+
? headers.get('x-test-user')
54+
: headers?.['x-test-user'];
55+
return uid ? { user: { id: uid } } : undefined;
56+
},
57+
},
58+
});
59+
},
60+
};
61+
}
62+
63+
describe('in-app notifications over a real hono server (integration, #3362)', () => {
64+
let kernel: ObjectKernel;
65+
let baseUrl: string;
66+
let messaging: MessagingService;
67+
68+
beforeAll(async () => {
69+
kernel = new ObjectKernel({ logLevel: 'silent' });
70+
// An in-memory driver backs persistence; ObjectQL (registered after the
71+
// driver so it discovers it) provides `objectql` + `data` + `manifest`;
72+
// MessagingServicePlugin registers the `notification` service the dispatcher
73+
// resolves and owns the inbox tables. Inline delivery (reliableDelivery:false)
74+
// writes the inbox row synchronously so `emit()` is observable immediately.
75+
await kernel.use(new DriverPlugin(new InMemoryDriver()));
76+
await kernel.use(new ObjectQLPlugin());
77+
await kernel.use(new MessagingServicePlugin({ reliableDelivery: false }));
78+
await kernel.use(fakeAuthPlugin());
79+
// port 0 → OS-assigned free port; resolved via getPort() after listening.
80+
await kernel.use(new HonoServerPlugin({ port: 0, registerStandardEndpoints: true }));
81+
await kernel.use(createDispatcherPlugin({ prefix: '/api/v1', securityHeaders: false, requireAuth: false }));
82+
83+
await kernel.bootstrap();
84+
85+
const httpServer = kernel.getService<any>('http.server');
86+
baseUrl = `http://127.0.0.1:${httpServer.getPort()}`;
87+
messaging = kernel.getService<MessagingService>('notification');
88+
}, 30_000);
89+
90+
afterAll(async () => {
91+
if (kernel) {
92+
await Promise.race([
93+
kernel.shutdown(),
94+
new Promise<void>((resolve) => setTimeout(resolve, 10_000)),
95+
]);
96+
}
97+
}, 30_000);
98+
99+
const authed = (path: string, init?: RequestInit) =>
100+
fetch(`${baseUrl}${path}`, {
101+
...init,
102+
headers: { 'x-test-user': TEST_USER, 'content-type': 'application/json', ...(init?.headers ?? {}) },
103+
});
104+
105+
it('resolves the notification service in discovery (declared === enforced)', async () => {
106+
const res = await fetch(`${baseUrl}/api/v1/discovery`);
107+
expect(res.status).toBe(200);
108+
const body = await res.json();
109+
const disc = body.data ?? body;
110+
// The route is advertised AND the service is reported available — the exact
111+
// `declared === enforced` invariant #3362 said was violated.
112+
expect(disc.routes?.notifications).toBe('/api/v1/notifications');
113+
expect(disc.services?.notification?.status).toBe('available');
114+
});
115+
116+
it('self-gates an unauthenticated request with 401 (route reachable, not a 404)', async () => {
117+
// The route IS mounted on hono — an anonymous caller reaches the handler and
118+
// is told to authenticate (401), rather than hitting the hono not-found (404)
119+
// that #3362 reported. That distinction is the whole bug.
120+
const res = await fetch(`${baseUrl}/api/v1/notifications`, { method: 'GET' });
121+
expect(res.status).toBe(401);
122+
});
123+
124+
it('lists, marks specific read, then marks all read — flipping receipts and clearing the unread count', async () => {
125+
// Deliver two unread notifications to the user through the real pipeline.
126+
await messaging.emit({ topic: 'deal.won', audience: [TEST_USER], payload: { title: 'Deal one', body: 'first' } });
127+
await messaging.emit({ topic: 'deal.won', audience: [TEST_USER], payload: { title: 'Deal two', body: 'second' } });
128+
129+
// GET /notifications → both show as unread.
130+
const listRes = await authed('/api/v1/notifications');
131+
expect(listRes.status).toBe(200);
132+
const list = await listRes.json();
133+
expect(list.success).toBe(true);
134+
expect(list.data.unreadCount).toBe(2);
135+
expect(list.data.notifications).toHaveLength(2);
136+
const ids: string[] = list.data.notifications.map((n: any) => n.id);
137+
138+
// POST /notifications/read — mark ONE specific notification read.
139+
const readOne = await authed('/api/v1/notifications/read', {
140+
method: 'POST',
141+
body: JSON.stringify({ ids: [ids[0]] }),
142+
});
143+
expect(readOne.status).toBe(200);
144+
expect((await readOne.json()).data).toMatchObject({ success: true, readCount: 1 });
145+
146+
const afterOne = await (await authed('/api/v1/notifications')).json();
147+
expect(afterOne.data.unreadCount).toBe(1);
148+
149+
// POST /notifications/read/all — clear the remainder.
150+
const readAll = await authed('/api/v1/notifications/read/all', { method: 'POST' });
151+
expect(readAll.status).toBe(200);
152+
expect((await readAll.json()).data).toMatchObject({ success: true, readCount: 1 });
153+
154+
// GET again → the badge is clear.
155+
const cleared = await (await authed('/api/v1/notifications')).json();
156+
expect(cleared.data.unreadCount).toBe(0);
157+
expect(cleared.data.notifications.every((n: any) => n.read === true)).toBe(true);
158+
159+
// The receipts were actually persisted as `read` (not merely a view-layer
160+
// computation) — the server-side state the console poll re-reads.
161+
const data = kernel.getService<any>('data');
162+
const receipts = await data.find('sys_notification_receipt', {
163+
where: { user_id: TEST_USER, channel: 'inbox' },
164+
});
165+
expect(receipts.length).toBe(2);
166+
expect(receipts.every((r: any) => r.state === 'read')).toBe(true);
167+
});
168+
});

pnpm-lock.yaml

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)