/** * run-smoke — interact.test.ts (Axis 4b — custom-action contract probes) * * Pure-logic coverage: body synthesis, status classification, probe collection * from pagespecs, and the probe runner driven by an INJECTED fetch (no live app). */ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import path from 'node:path'; import { SENTINEL_GUID, synthesizeActionBody, classifyActionStatus, collectActionProbes, runActionProbes, runInteractionAxis, type FetchLike, } from '../interact.js'; describe('interact / synthesizeActionBody', () => { it('produces a deterministic valid value per param type and omits files', () => { const body = synthesizeActionBody([ { name: 'reason', type: 'text' }, { name: 'amount', type: 'number' }, { name: 'when', type: 'date' }, { name: 'active', type: 'boolean' }, { name: 'target', type: 'lookup' }, { name: 'doc', type: 'file' }, ]); expect(body).toEqual({ reason: 'SMOKE-reason', amount: 1, when: '2026-01-15', active: true, target: SENTINEL_GUID, // doc omitted (multipart) }); }); }); describe('interact / classifyActionStatus', () => { const cases: Array<[number | null, string]> = [ [200, 'pass'], [204, 'pass'], [404, 'pass'], // sentinel row not found — reached the handler, no mutation [401, 'auth-gated'], [403, 'auth-gated'], [415, 'fail'], // body/content-type rejected (the empty-body gap) [405, 'fail'], // wrong verb [500, 'fail'], // handler threw on a valid body [400, 'indeterminate'], // synth body may be insufficient [422, 'indeterminate'], [null, 'fail'], // network error ]; for (const [status, verdict] of cases) { it(`status ${status} → ${verdict}`, () => { expect(classifyActionStatus(status).verdict).toBe(verdict); }); } it('tags 415 as smoke.action-body and 5xx as smoke.5xx', () => { expect(classifyActionStatus(415).reason).toBe('smoke.action-body'); expect(classifyActionStatus(500).reason).toBe('smoke.5xx'); expect(classifyActionStatus(405).reason).toBe('smoke.4xx'); }); }); describe('interact / collectActionProbes + runActionProbes', () => { let root: string; let moduleRoot: string; beforeEach(() => { root = mkdtempSync(path.join(tmpdir(), 'ss-interact-')); moduleRoot = path.join(root, 'ba', 'CRM', 'crm'); mkdirSync(path.join(moduleRoot, 'pagespecs'), { recursive: true }); }); afterEach(() => rmSync(root, { recursive: true, force: true })); function writePagespec(name: string, actions: unknown[]): void { const md = ` \`\`\`json { "screenCode": "SCR-CRM-PIPELINE-OPP-001", "module": "crm", "section": "opportunites", "entity": "Opportunity", "view": "detail", "actions": ${JSON.stringify(actions)} } \`\`\` `; writeFileSync(path.join(moduleRoot, 'pagespecs', name), md); } it('derives one probe per custom action with the sentinel-id row URL + bulk ids', async () => { writePagespec('Opportunity.detail.md', [ { code: 'archive', kind: 'api', scope: 'row', endpoint: 'archive', httpMethod: 'POST' }, { code: 'bulkClose', kind: 'api', scope: 'bulk', endpoint: 'bulk-close', httpMethod: 'POST', payloadParameters: [{ name: 'note', type: 'text' }] }, { code: 'edit', kind: 'api', scope: 'row', httpMethod: 'PUT' }, // CRUD — excluded { code: 'open', kind: 'navigate', scope: 'row' }, // navigate — excluded ]); const probes = await collectActionProbes(moduleRoot); expect(probes.map(p => p.code).sort()).toEqual(['archive', 'bulkClose']); const row = probes.find(p => p.code === 'archive')!; expect(row.url).toBe(`/api/crm/opportunites/${SENTINEL_GUID}/archive`); const bulk = probes.find(p => p.code === 'bulkClose')!; expect(bulk.url).toBe('/api/crm/opportunites/bulk/bulk-close'); expect(bulk.body).toEqual({ ids: [SENTINEL_GUID], payload: { note: 'SMOKE-note' } }); }); it('runs probes through an injected fetch and classifies each', async () => { writePagespec('Opportunity.detail.md', [ { code: 'archive', kind: 'api', scope: 'row', endpoint: 'archive', httpMethod: 'POST' }, ]); const probes = await collectActionProbes(moduleRoot); const fakeFetch: FetchLike = async () => ({ status: 415 }); const results = await runActionProbes('http://localhost:5000', probes, { token: 'admin-jwt', fetchImpl: fakeFetch }); expect(results).toHaveLength(1); expect(results[0].verdict).toBe('fail'); expect(results[0].reason).toBe('smoke.action-body'); }); it('sends the Authorization header when a token is supplied', async () => { writePagespec('Opportunity.detail.md', [{ code: 'archive', kind: 'api', scope: 'row', endpoint: 'archive', httpMethod: 'POST' }]); const probes = await collectActionProbes(moduleRoot); let seenAuth = ''; const fakeFetch: FetchLike = async (_u, init) => { seenAuth = init.headers['Authorization'] ?? ''; return { status: 404 }; }; const results = await runActionProbes('http://localhost:5000', probes, { token: 'admin-jwt', fetchImpl: fakeFetch }); expect(seenAuth).toBe('Bearer admin-jwt'); expect(results[0].verdict).toBe('pass'); // 404 = reached handler, no mutation }); it('runInteractionAxis: not run without a module root', async () => { const r = await runInteractionAxis('http://localhost:5000', undefined); expect(r.ran).toBe(false); }); it('runInteractionAxis: fails on a 415, passes on a 404', async () => { writePagespec('Opportunity.detail.md', [{ code: 'archive', kind: 'api', scope: 'row', endpoint: 'archive', httpMethod: 'POST' }]); const fail = await runInteractionAxis('http://localhost:5000', moduleRoot, { token: 't', fetchImpl: async () => ({ status: 415 }) }); expect(fail.failures).toHaveLength(1); const ok = await runInteractionAxis('http://localhost:5000', moduleRoot, { token: 't', fetchImpl: async () => ({ status: 404 }) }); expect(ok.failures).toHaveLength(0); }); it('runInteractionAxis: no token + all auth-gated → note, no hard failure', async () => { writePagespec('Opportunity.detail.md', [{ code: 'archive', kind: 'api', scope: 'row', endpoint: 'archive', httpMethod: 'POST' }]); const r = await runInteractionAxis('http://localhost:5000', moduleRoot, { fetchImpl: async () => ({ status: 401 }) }); expect(r.ran).toBe(true); expect(r.authenticated).toBe(false); expect(r.failures).toHaveLength(0); expect(r.reason).toContain('no admin token'); }); });