/** * scaffold-api-client — offline-write outbox emission (PWA v1). * * `pwa.offline: 'write'` emits the entity's outbox spec module (modeled on the * socle's timeEntryOutbox dogfood) + folds pending writes into the list/detail * hooks via useOutboxOverlay. `versioned` surfaces rowVersion on the DTOs. * Anything else stays byte-identical (the untouched legacy suite pins that). */ import { describe, it, expect } from 'vitest' import { generate } from '../generate.js' import { validate } from '../validate.js' import type { ScaffoldApiClientInput } from '../types.js' function fixture(entityOverrides: Record = {}, specOverrides: Partial = {}): ScaffoldApiClientInput { return { module: 'hrm', appCode: 'client', projectPath: '/web', routeMode: 'integration', useScreens: false, httpClient: 'smartstack', entities: [ { name: 'Employee', section: 'employees', parentIdParam: 'parentId', fields: [ { name: 'firstName', type: 'string', required: true }, { name: 'lastName', type: 'string', required: true }, ], customActions: [], hasDashboard: false, versioned: false, ...entityOverrides, }, ], ...specOverrides, } as ScaffoldApiClientInput } const writeFixture = () => fixture({ pwa: { offline: 'write' }, versioned: true }) describe('outbox spec module emission', () => { it('emits outbox/{entity}Outbox.ts only for offline-write entities', () => { const none = generate(fixture()).find((f) => f.path.includes('/outbox/')) expect(none).toBeUndefined() const read = generate(fixture({ pwa: { offline: 'read' } })).find((f) => f.path.includes('/outbox/')) expect(read).toBeUndefined() const write = generate(writeFixture()).find((f) => f.path.includes('/outbox/')) expect(write?.path).toBe('src/features/client/hrm/employee/outbox/employeeOutbox.ts') }) it('module carries the resource consts, BASE path, three registered specs and the register fn', () => { const content = generate(writeFixture()).find((f) => f.path.includes('/outbox/'))!.content expect(content).toContain("export const EMPLOYEE_RESOURCE = 'client.hrm.employees';") expect(content).toContain("export const EMPLOYEE_CREATE = 'client.hrm.employees.create';") expect(content).toContain("const BASE_PATH = '/api/hrm/employees';") expect(content.match(/OutboxRegistry\.register\(/g)).toHaveLength(3) expect(content).toContain('export function registerEmployeeOutbox(): void {') expect(content).toContain('remapTempIds: remapId') }) it('specs omit idempotencyKey and onConflict (interceptor/server-wins contract)', () => { const content = generate(writeFixture()).find((f) => f.path.includes('/outbox/'))!.content expect(content).not.toMatch(/^\s*idempotencyKey\s*:/m) expect(content).not.toMatch(/^\s*onConflict\s*:/m) }) it('emits both deterministic overlay reducers typed on the entity DTOs', () => { const content = generate(writeFixture()).find((f) => f.path.includes('/outbox/'))!.content expect(content).toContain('export function applyEmployeeListOverlay(') expect(content).toContain('export function applyEmployeeDetailOverlay(') expect(content).toContain('EmployeeListDto[] | null') expect(content).toContain('EmployeeDetailDto | null') }) }) describe('hook overlay wiring', () => { it('list + detail hooks fold pending writes via useOutboxOverlay', () => { const hooks = generate(writeFixture()).find((f) => f.path.endsWith('hooks/useEmployee.ts'))!.content expect(hooks).toContain("import { useOutboxOverlay } from '@atlashub/smartstack';") expect(hooks).toContain("from '../outbox/employeeOutbox'") expect(hooks).toContain('useOutboxOverlay(EMPLOYEE_RESOURCE, data?.items ?? null, applyEmployeeListOverlay)') expect(hooks).toContain('useOutboxOverlay(EMPLOYEE_RESOURCE, data ?? null, applyEmployeeDetailOverlay)') }) it('non-write hooks stay untouched (no outbox trace)', () => { const hooks = generate(fixture()).find((f) => f.path.endsWith('hooks/useEmployee.ts'))!.content expect(hooks).not.toContain('useOutboxOverlay') expect(hooks).toContain('return { data, isLoading, error, refetch };') }) }) describe('versioned DTO surface', () => { it('DetailDto + UpdateDto gain rowVersion?: string when versioned', () => { const types = generate(fixture({ versioned: true })).find((f) => f.path.endsWith('types/index.ts'))!.content expect(types.match(/rowVersion\?: string;/g)).toHaveLength(2) }) it('non-versioned DTOs carry no rowVersion', () => { const types = generate(fixture()).find((f) => f.path.endsWith('types/index.ts'))!.content expect(types).not.toContain('rowVersion') }) }) describe('offline-write validation gates', () => { it("refuses 'write' without versioned", () => { const res = validate(fixture({ pwa: { offline: 'write' } })) expect(res.valid).toBe(false) expect(res.errors.join('\n')).toMatch(/versioned: true/) }) it("refuses 'write' with parentPath", () => { const res = validate(fixture({ pwa: { offline: 'write' }, versioned: true, parentPath: '/api/hrm/sites/{parentId}/employees' })) expect(res.valid).toBe(false) expect(res.errors.join('\n')).toMatch(/parentPath/) }) it("refuses 'write' in screens route mode", () => { const res = validate(fixture({ pwa: { offline: 'write' }, versioned: true }, { routeMode: 'screens' })) expect(res.valid).toBe(false) expect(res.errors.join('\n')).toMatch(/screens/) }) it("accepts 'write' + versioned on the integration stratum", () => { expect(validate(writeFixture()).valid).toBe(true) }) })