import { describe, expect, test } from 'bun:test'; import { composeInterviewBody, decideInterviewDelivery, describeQuestion, isRefusedFamily, } from './interview-responder'; const headless = { hasTty: false, hasBidirectionalRoute: true }; describe('isRefusedFamily', () => { test.each(['secret.required.namecheap.ddns_passwords', 'secret.required.forgejo.admin_token'])( '%p is refused', (type) => { expect(isRefusedFamily(type)).toBe(true); }, ); test.each([ 'config.required.caddy.hostname', 'ensure.required.namecheap.managed_domain', 'interview.required.service:proxmox.node', 'aspect.required.technitium.dns', ])('%p is not refused', (type) => { expect(isRefusedFamily(type)).toBe(false); }); }); describe('decideInterviewDelivery', () => { // The refusal is checked FIRST, so a secret is declined even on a headless // box with a working transport. The deploy waits for a terminal, which is // correct — the alternative is leaking a credential for convenience. test('a secret question is refused even when nothing else could answer it', () => { const decision = decideInterviewDelivery({ eventType: 'secret.required.forgejo.admin_token', ...headless, }); expect(decision.deliver).toBe(false); expect(decision.reason).toContain('terminal'); }); test('a secret question is refused with a TTY too', () => { const decision = decideInterviewDelivery({ eventType: 'secret.required.forgejo.admin_token', hasTty: true, hasBidirectionalRoute: true, }); expect(decision.deliver).toBe(false); }); // Without this a deploy started from a laptop appears to hang while // silently waiting for a text message. test('a terminal wins when one is attached', () => { const decision = decideInterviewDelivery({ eventType: 'config.required.caddy.hostname', hasTty: true, hasBidirectionalRoute: true, }); expect(decision.deliver).toBe(false); expect(decision.reason).toContain('terminal responder'); }); test('a non-secret question is delivered headlessly', () => { expect( decideInterviewDelivery({ eventType: 'config.required.caddy.hostname', ...headless }), ).toEqual({ deliver: true }); }); // Delivering to a route that cannot reply produces a question nobody can // answer, which reads to the operator as the deploy having hung. test('nothing is delivered when no route can receive a reply', () => { const decision = decideInterviewDelivery({ eventType: 'config.required.caddy.hostname', hasTty: false, hasBidirectionalRoute: false, }); expect(decision.deliver).toBe(false); expect(decision.reason).toContain('never be answered'); }); }); describe('composeInterviewBody', () => { test('leads with the question, ends with a bare reply instruction', () => { const body = composeInterviewBody({ scope: 'caddy', key: 'hostname', description: 'Public hostname for the site', token: 'K7QM2X', }); expect(body.split('\n')[0]).toContain('caddy needs: hostname'); expect(body.endsWith('reply K7QM2X ')).toBe(true); }); test('works without a description', () => { const body = composeInterviewBody({ scope: 'caddy', key: 'hostname', token: 'K7QM2X' }); expect(body).toContain('caddy needs: hostname'); }); }); /** * The families disagree on payload shape, and reading the wrong field means an * operator gets the raw event type instead of the question. */ describe('describeQuestion', () => { test("uses the interview family's message — it IS the question", () => { const q = describeQuestion('interview.required.service.node', { scope: 'service:proxmox-home-lab', key: 'default_target_node', message: 'Which Proxmox node should new containers land on?', }); expect(q.scope).toBe('service:proxmox-home-lab'); expect(q.key).toBe('Which Proxmox node should new containers land on?'); }); // config.required carries `module`, not `scope`, and has no prose at all. test('composes from module + key when there is no message', () => { const q = describeQuestion('config.required.caddy.acme_email', { module: 'caddy', key: 'acme_email', description: 'Address ACME registers with', type: 'string', required: true, }); expect(q.scope).toBe('caddy'); expect(q.key).toBe('acme_email'); expect(q.description).toBe('Address ACME registers with'); }); // Never render "undefined needs: value" at 3am. test('falls back to the event type rather than undefined', () => { const q = describeQuestion('ensure.required.forgejo.runner', {}); expect(q.scope).toBe('ensure.required.forgejo.runner'); expect(q.key).toBe('value'); }); });