|
| 1 | +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * ADR-0076 D11 step ③ (#2462) — the thin domain-handler registry seam. |
| 5 | + * |
| 6 | + * Two layers under test: |
| 7 | + * 1. `DomainHandlerRegistry` matching semantics (first-match, exact vs |
| 8 | + * prefix, method restriction) — deliberately faithful to the legacy |
| 9 | + * if-chain, rough edges included. |
| 10 | + * 2. `HttpDispatcher` integration: the four seeded builtin domains |
| 11 | + * (/health /ready /analytics /i18n) behave exactly as their legacy |
| 12 | + * if-chain branches did, and `registerDomainHandler` is the public |
| 13 | + * seam follow-up domain PRs will use. |
| 14 | + */ |
| 15 | + |
| 16 | +import { describe, it, expect, vi } from 'vitest'; |
| 17 | +import { HttpDispatcher } from './http-dispatcher.js'; |
| 18 | +import { DomainHandlerRegistry } from './domain-handler-registry.js'; |
| 19 | +import type { DomainHandler } from './domain-handler-registry.js'; |
| 20 | + |
| 21 | +const okHandler = (tag: string): DomainHandler => |
| 22 | + async () => ({ handled: true, response: { status: 200, body: { tag } } }); |
| 23 | + |
| 24 | +/** Minimal kernel: objectql + optional extra services. */ |
| 25 | +function makeKernel(services: Record<string, any> = {}, state = 'running') { |
| 26 | + const objectql = { |
| 27 | + find: vi.fn().mockResolvedValue([]), |
| 28 | + getObjects: vi.fn().mockReturnValue({}), |
| 29 | + registry: { getObject: vi.fn().mockReturnValue(null), getRegisteredTypes: vi.fn().mockReturnValue([]) }, |
| 30 | + }; |
| 31 | + const all: Record<string, any> = { objectql, ...services }; |
| 32 | + const kernel: any = { |
| 33 | + getState: () => state, |
| 34 | + getService: (name: string) => all[name] ?? null, |
| 35 | + getServiceAsync: async (name: string) => all[name] ?? null, |
| 36 | + context: { getService: (name: string) => all[name] ?? null }, |
| 37 | + }; |
| 38 | + return kernel; |
| 39 | +} |
| 40 | + |
| 41 | +function makeDispatcher(services: Record<string, any> = {}, state = 'running') { |
| 42 | + return new HttpDispatcher(makeKernel(services, state), undefined, { |
| 43 | + enforceProjectMembership: false, |
| 44 | + }); |
| 45 | +} |
| 46 | + |
| 47 | +// --------------------------------------------------------------------------- |
| 48 | +// DomainHandlerRegistry matching semantics |
| 49 | +// --------------------------------------------------------------------------- |
| 50 | + |
| 51 | +describe('DomainHandlerRegistry', () => { |
| 52 | + it('resolves in registration order, first match wins', () => { |
| 53 | + const registry = new DomainHandlerRegistry(); |
| 54 | + const first = okHandler('first'); |
| 55 | + const second = okHandler('second'); |
| 56 | + registry.register({ prefix: '/a', handler: first }); |
| 57 | + registry.register({ prefix: '/a', handler: second }); |
| 58 | + expect(registry.resolve('/a/x', 'GET')?.handler).toBe(first); |
| 59 | + }); |
| 60 | + |
| 61 | + it("match: 'exact' does not claim sub-paths; default prefix match does (legacy startsWith, rough edges included)", () => { |
| 62 | + const registry = new DomainHandlerRegistry(); |
| 63 | + registry.register({ prefix: '/health', match: 'exact', handler: okHandler('h') }); |
| 64 | + registry.register({ prefix: '/i18n', handler: okHandler('i') }); |
| 65 | + expect(registry.resolve('/health', 'GET')).toBeDefined(); |
| 66 | + expect(registry.resolve('/health/deep', 'GET')).toBeUndefined(); |
| 67 | + expect(registry.resolve('/i18n/locales', 'GET')).toBeDefined(); |
| 68 | + // Faithful legacy semantics: bare startsWith also matches '/i18nxx'. |
| 69 | + expect(registry.resolve('/i18nxx', 'GET')).toBeDefined(); |
| 70 | + }); |
| 71 | + |
| 72 | + it('restricts by method when `methods` is set (case-insensitive on input)', () => { |
| 73 | + const registry = new DomainHandlerRegistry(); |
| 74 | + registry.register({ prefix: '/health', match: 'exact', methods: ['GET'], handler: okHandler('h') }); |
| 75 | + expect(registry.resolve('/health', 'get')).toBeDefined(); |
| 76 | + expect(registry.resolve('/health', 'POST')).toBeUndefined(); |
| 77 | + }); |
| 78 | + |
| 79 | + it('rejects a prefix that does not start with a slash', () => { |
| 80 | + const registry = new DomainHandlerRegistry(); |
| 81 | + expect(() => registry.register({ prefix: 'health', handler: okHandler('h') })).toThrow(/prefix/); |
| 82 | + }); |
| 83 | +}); |
| 84 | + |
| 85 | +// --------------------------------------------------------------------------- |
| 86 | +// Dispatcher integration — seeded builtin domains keep legacy behavior |
| 87 | +// --------------------------------------------------------------------------- |
| 88 | + |
| 89 | +describe('HttpDispatcher domain registry (D11 step ③)', () => { |
| 90 | + it('GET /health serves the liveness payload', async () => { |
| 91 | + const result = await makeDispatcher().dispatch('GET', '/health', undefined, {}, {} as any); |
| 92 | + expect(result.handled).toBe(true); |
| 93 | + expect(result.response?.status).toBe(200); |
| 94 | + expect(result.response?.body?.data?.status).toBe('ok'); |
| 95 | + }); |
| 96 | + |
| 97 | + it('GET /ready reflects kernel state: running → 200, booting → 503', async () => { |
| 98 | + const ready = await makeDispatcher({}, 'running').dispatch('GET', '/ready', undefined, {}, {} as any); |
| 99 | + expect(ready.response?.status).toBe(200); |
| 100 | + expect(ready.response?.body?.data?.state).toBe('running'); |
| 101 | + |
| 102 | + const booting = await makeDispatcher({}, 'initializing').dispatch('GET', '/ready', undefined, {}, {} as any); |
| 103 | + expect(booting.response?.status).toBe(503); |
| 104 | + expect(booting.response?.body?.error?.details?.state).toBe('initializing'); |
| 105 | + }); |
| 106 | + |
| 107 | + it('non-GET /health is NOT claimed by the health domain (falls through the legacy chain)', async () => { |
| 108 | + const result = await makeDispatcher().dispatch('POST', '/health', undefined, {}, {} as any); |
| 109 | + // Same as before the registry: no branch claims POST /health. |
| 110 | + expect(result.response?.status ?? 404).not.toBe(200); |
| 111 | + }); |
| 112 | + |
| 113 | + it('/i18n keeps its in-handler 501 when the i18n service is absent', async () => { |
| 114 | + const result = await makeDispatcher().dispatch('GET', '/i18n/locales', undefined, {}, {} as any); |
| 115 | + expect(result.handled).toBe(true); |
| 116 | + expect(result.response?.status).toBe(501); |
| 117 | + }); |
| 118 | + |
| 119 | + it('/i18n/locales serves from the i18n service when present', async () => { |
| 120 | + const i18n = { getLocales: vi.fn().mockReturnValue(['en', 'zh-CN']), getTranslations: vi.fn().mockReturnValue({}) }; |
| 121 | + const result = await makeDispatcher({ i18n }).dispatch('GET', '/i18n/locales', undefined, {}, {} as any); |
| 122 | + expect(result.response?.status).toBe(200); |
| 123 | + expect(result.response?.body?.data?.locales).toEqual(['en', 'zh-CN']); |
| 124 | + }); |
| 125 | + |
| 126 | + it('/analytics bridges to the analytics service exactly as the legacy branch did', async () => { |
| 127 | + const analytics = { |
| 128 | + query: vi.fn().mockResolvedValue({ rows: [{ n: 1 }] }), |
| 129 | + analyticsQuery: vi.fn().mockResolvedValue({ rows: [{ n: 1 }] }), |
| 130 | + }; |
| 131 | + const result = await makeDispatcher({ analytics }).dispatch( |
| 132 | + 'POST', '/analytics/query', { metric: 'count' }, {}, {} as any, |
| 133 | + ); |
| 134 | + expect(result.handled).toBe(true); |
| 135 | + // The bridge consulted the service (whichever entry point it uses). |
| 136 | + expect( |
| 137 | + analytics.query.mock.calls.length + analytics.analyticsQuery.mock.calls.length, |
| 138 | + ).toBeGreaterThan(0); |
| 139 | + }); |
| 140 | + |
| 141 | + it('registerDomainHandler is the public seam: a service-owned domain resolves before the legacy chain', async () => { |
| 142 | + const dispatcher = makeDispatcher(); |
| 143 | + const handler = vi.fn(async (req: any) => ({ |
| 144 | + handled: true as const, |
| 145 | + response: { status: 200, body: { echo: req.path } }, |
| 146 | + })); |
| 147 | + dispatcher.registerDomainHandler({ prefix: '/custom-domain', handler }); |
| 148 | + |
| 149 | + const result = await dispatcher.dispatch('GET', '/custom-domain/thing', undefined, { q: '1' }, {} as any); |
| 150 | + expect(handler).toHaveBeenCalledTimes(1); |
| 151 | + expect(handler.mock.calls[0][0]).toMatchObject({ path: '/custom-domain/thing', method: 'GET' }); |
| 152 | + expect(result.response?.body?.echo).toBe('/custom-domain/thing'); |
| 153 | + }); |
| 154 | +}); |
0 commit comments