|
| 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 | +}); |
0 commit comments