/* * ★ **그물이 실제로 줍는가** — 배선. * * 판단은 순수 함수가 지킨다(`retry-plan.test.ts`). 여기서 보는 것은 **그 답이 실제 경로에 서 * 있는가**다: 표를 읽었나, 어댑터를 불렀나, 시도 횟수와 다음 시각을 적었나, 한 조치가 터져도 * 다음을 보나. * * 이 저장소에서 가장 자주 나는 결함이 「자리는 있고 길이 없다」이고, 오늘 하루 그 부류를 여러 번 * 고쳤다. 그물이 그 부류로 나면 **승인된 조치가 조용히 안 나간다.** */ import { test } from 'node:test' import assert from 'node:assert/strict' import { sweepPendingDispatch } from '../server/service/actuation/sweep-dispatch.js' import type { CommandStore, StoredCommand } from '../server/service/actuation/command-dispatcher.js' const DOMAIN = 'd-1' const AT = '2026-09-04T02:00:00.000Z' const cmd = (over: Partial = {}): StoredCommand => ({ id: 'cmd-1', instanceId: 'twin-a', kind: 'production.release', origin: 'rule', state: 'approved', reversible: false, /* 되돌릴 수 없는 조치는 **승인 기록**이 있어야 넘어간다(§`asApproved`) — 그 모양이 이것이다. */ approval: { by: 'lead', at: AT }, ...over }) as StoredCommand /** * 표 하나와 가짜 커넥터 — `save` 가 무엇을 적었는지 본다. * * 의존을 인자로 받는 서명이라 여기서 다 흉내낼 수 있다. 직접 부르는 서명이었을 때는 이 시험이 * DB·모듈 부팅을 끌어와 아예 뜨지 않았다 — 그것이 이 파일을 그 모양으로 바꾼 이유다. */ function bench(rows: StoredCommand[], over: Record = {}) { const table = new Map(rows.map(r => [r.id, { ...r }])) const saves: StoredCommand[] = [] const store: CommandStore = { load: async (_d, id) => (table.has(id) ? { ...table.get(id)! } : null), save: async (_d, c) => { saves.push({ ...c }) table.set(c.id, { ...c }) }, pendingDispatch: async () => [...table.values()] } as any const calls: string[] = [] const deps = { store, /* 어댑터가 늘 받아 준다 — 「집었나」를 보는 시험이라 저쪽 사정은 다른 파일이 지킨다. */ actuate: async (c: any) => { calls.push(c.id) return { ok: true, ref: `WO-${c.id}` } }, idempotent: async () => false, ...over } as any return { store, table, saves, deps, calls } } /* ── 첫 시도를 줍는다 ─────────────────────────────────────────────────────── */ test('★ 승인만 되고 안 나간 것을 줍는다 — 승인 직후 발신이 놓친 그 창이다', async () => { const { table, deps, calls } = bench([cmd()]) const report = await sweepPendingDispatch(DOMAIN, deps) /* 어댑터가 없으므로 `routeActuation` 이 거절한다 — 그래도 **집었다**는 것이 이 시험의 주제다. */ assert.equal(report.seen, 1) assert.equal(table.get('cmd-1')!.state !== 'approved', true, '집지 않아 상태가 그대로다') }) test('★ 시도 횟수와 다음 시각을 적는다 — 메모리에 두면 재기동에 사라진다', async () => { const { table, deps, calls } = bench([cmd()]) await sweepPendingDispatch(DOMAIN, deps) const after = table.get('cmd-1')! assert.equal(after.attempts, 1, '첫 시도가 1로 적혀야 한다') assert.ok(after.nextAttemptAt, '다음 시각이 없으면 물러섬이 재기동에 사라진다') assert.ok(Date.parse(after.nextAttemptAt!) > Date.now(), '다음 시각이 과거다') }) /* ── 집지 않는 것들 ───────────────────────────────────────────────────────── */ test('★ 종착은 집지 않는다', async () => { const { saves, deps } = bench([cmd({ state: 'acked', attempts: 1 })]) const report = await sweepPendingDispatch(DOMAIN, deps) assert.equal(report.dispatched, 0) assert.equal(report.skipped['not-dispatchable'], 1) assert.deepEqual(saves, [], '집지 않았는데 표를 고쳤다') }) test('★ 어댑터가 다시 될 일인지 말하지 않았으면 집지 않는다 — 목록에 「모름」으로 남는다', async () => { const { deps, store } = bench([cmd({ state: 'failed', attempts: 1, error: '저쪽이 안 받았습니다' })]) const report = await sweepPendingDispatch(DOMAIN, deps) assert.equal(report.skipped['outlook-unknown'], 1) assert.equal(report.dispatched, 0) }) test('★ 멱등을 선언하지 않은 커넥터는 재시도하지 않는다', async () => { const { deps, store } = bench([cmd({ state: 'failed', attempts: 1, retry: 'again' })]) const report = await sweepPendingDispatch(DOMAIN, deps) assert.equal(report.skipped['not-idempotent'], 1) }) test('★ 영원히 안 될 것은 집지 않는다', async () => { const { deps, store } = bench([cmd({ state: 'failed', attempts: 1, retry: 'never' })]) assert.equal((await sweepPendingDispatch(DOMAIN, deps)).skipped['never'], 1) }) test('★ 물러섬이 안 끝났으면 기다린다', async () => { const later = new Date(Date.now() + 300_000).toISOString() const { saves, deps } = bench([cmd({ state: 'failed', attempts: 1, retry: 'again', nextAttemptAt: later })]) const report = await sweepPendingDispatch(DOMAIN, deps) /* 멱등 선언이 없어 그쪽에서 먼저 걸린다 — 어느 이유든 **집지 않았고 표를 안 고쳤다**가 핵심이다. */ assert.equal(report.dispatched, 0) assert.deepEqual(saves, []) }) /* ── 저장소가 못 내면 아무것도 하지 않는다 ──────────────────────────────── */ test('★ 대기 줄을 못 내는 저장소에서는 아무것도 하지 않는다 — 옛 거동이 깨지지 않게', async () => { const deps = { store: { load: async () => null, save: async () => {} }, actuate: async () => ({ ok: true }), idempotent: async () => false } as any const report = await sweepPendingDispatch(DOMAIN, deps) assert.deepEqual(report, { seen: 0, dispatched: 0, skipped: {} }) }) test('★ 표 읽기가 터져도 그물이 죽지 않는다', async () => { const deps = { store: { load: async () => null, save: async () => {}, pendingDispatch: async () => { throw new Error('DB 가 없다') } }, actuate: async () => ({ ok: true }), idempotent: async () => false } as any const report = await sweepPendingDispatch(DOMAIN, deps) assert.equal(report.seen, 0) }) /* ── 한 조치가 터져도 다음을 본다 ───────────────────────────────────────── */ test('★ 한 조치에서 터져도 다음 조치를 본다 — 그물이 한 건에 멈추면 뒤가 다 막힌다', async () => { const { deps, store } = bench([cmd({ id: 'a' }), cmd({ id: 'b' })]) const original = store.save let first = true ;(store as any).save = async (d: string, c: StoredCommand) => { if (first) { first = false throw new Error('첫 저장이 터졌다') } return original!(d, c) } const report = await sweepPendingDispatch(DOMAIN, deps) assert.equal(report.seen, 2, '둘 다 읽었다') /* 하나가 터졌어도 바퀴가 끝났다 — 던지면 이 단정에 오지 못한다. */ assert.ok(report.dispatched + Object.values(report.skipped).reduce((a, b) => a + b, 0) >= 1) }) /* ── 한 바퀴 상한 ─────────────────────────────────────────────────────────── */ test('★ 대기 줄이 커도 한 주기를 독차지하지 않는다', async () => { const many = Array.from({ length: 120 }, (_, i) => cmd({ id: `c-${i}`, state: 'acked', attempts: 1 })) const { deps, store } = bench(many) const report = await sweepPendingDispatch(DOMAIN, deps) assert.equal(report.seen, 120, '표는 다 읽는다') assert.equal(report.skipped['not-dispatchable'], 50, '한 바퀴에 보는 것은 상한까지다') })