/* * **밀어 받는 길의 번호** — 빠진 것을 알아채고, 재기동을 넘어 이어받는다. * * ── 왜 이 시험이 있나 (2026-08-31) ──────────────────────────────────────── * 훅이 놓치는 것은 예외가 아니라 정상이다. 우리가 내려가 있을 때, 그쪽이 못 보냈을 때, 네트워크가 * 끊겼을 때. 그 대비가 드문 폴링 하나뿐이었다 — 폴링은 「놓친 것이 있나」를 값으로 알아내는 것이고, * 번호는 빠진 것을 곧바로 안다. * * ── 왜 순수 함수 시험만으로 부족한가 ───────────────────────────────────── * 번호 판정 자체는 계약 쪽에서 확인한다(`ops-contract/test/webhook-contract.test.ts`). 여기서 보는 * 것은 **배선**이다 — 판정이 응답이 되는가, 커서에 적히는가, 다시 세운 뒤에 읽히는가. * * 이 저장소에서 가장 자주 나는 결함이 「자리는 있고 길이 없다」이고, 커서가 정확히 그 모양이었다. * `TwinReference.liveCursor` 는 재기동을 넘어 사는 자리인데 **훅 경로가 그것을 읽지도 쓰지도 않았다.** * 쓰는 곳은 폴링 하나뿐이었다. * * 그래서 마지막 시험이 저장본을 그대로 두고 **핸들러를 새로 부른다.** 한 프로세스 안에서만 확인하면 * 「적히나」는 보이지만 「다시 세우면 읽히나」는 보이지 않는다. */ import { test } from 'node:test' import assert from 'node:assert/strict' import { WEBHOOK_STATUS, webhookSenderAction } from '@operato/ops-contract' import { handleHook, type HookStore } from '../server/service/reference/reference-hook.js' import { registerAdapterType, getAdapter } from '../server/service/reference/reference-adapter.js' const DOMAIN = 'd-1' const SOURCE = 'operato-plant' const TWIN = 'twin-a' const SECRET = 'shared-secret' const SCOPE = 'EVENTS' /** 커서 열쇠에는 트윈이 함께 들어간다 — 연결 하나가 트윈 여럿을 만든다. */ const KEY = `${TWIN}::${SCOPE}` /** 보낸 봉투를 그대로 레코드로 내는 커넥터 — 옮길 것이 없으므로 번호만 꺼낸다. */ const ADAPTER_TYPE = 'test-numbered-push' if (!getAdapter(ADAPTER_TYPE)) { registerAdapterType({ type: ADAPTER_TYPE, label: 'numbered push (test)', handleInbound: (_cfg: any, _site: any, body: any) => ({ items: body?.items ?? [{ record: { moverId: 'eq-1', kind: 'press', status: 'idle' }, seq: body?.seq }], scope: body?.scope }) } as any) } /** 저장본 하나 — 프로세스가 아니라 여기에 산다. 그래서 핸들러를 새로 불러도 남는다. */ function makeStore(initial?: unknown): HookStore & { row: any } { const row = { id: 'ref-1', source: SOURCE, adapterType: ADAPTER_TYPE, connectionConfig: { hookSecret: SECRET }, scopeSpec: { produced: [{ siteId: 'site-a', instanceId: TWIN }] }, liveCursor: initial } return { row, load: async (domainId: string, source: string) => (domainId === DOMAIN && source === SOURCE ? row : null), saveCursor: async (refId: string, cursor: unknown) => { assert.equal(refId, row.id) /* 저장소를 흉내 내는 것이라 참조를 그대로 두지 않고 복사해 넣는다 — 같은 객체면 안 적혀도 통과한다. */ row.liveCursor = JSON.parse(JSON.stringify(cursor)) } } } const applied = (_id: string, records: unknown[]) => ({ applied: records.length, duplicates: 0, rejected: [] }) function push(store: HookStore, seq: number | undefined, opts: { scope?: string; ingest?: any } = {}) { return handleHook({ domainId: DOMAIN, source: SOURCE, instanceId: TWIN, headers: { 'x-twin-hook-secret': SECRET }, body: { seq, scope: 'scope' in opts ? opts.scope : SCOPE }, nowMs: Date.parse('2026-08-31T02:00:00.000Z'), ingest: opts.ingest ?? applied, store }) } test('★ 첫 봉투를 받고 그 번호를 커서에 적는다', async () => { const store = makeStore() const out = await push(store, 918342) assert.equal(out.status, WEBHOOK_STATUS.ok) assert.equal(out.body.sequenceChecked, true) assert.equal(out.body.lastSeq, 918342) assert.equal(store.row.liveCursor?.streams?.[KEY]?.seq, 918342, '커서에 적히지 않았다') }) test('★ 건너뛴 번호는 409 로 답하고 그 앞부터 달라고 한다', async () => { const store = makeStore({ streams: { [KEY]: { seq: 4 } } }) const out = await push(store, 7) assert.equal(out.status, WEBHOOK_STATUS.sequenceGap) assert.equal(out.body.expectedSeq, 5) assert.equal(out.body.lastSeq, 4) assert.equal(out.body.scope, SCOPE, '어느 줄의 번호인지 함께 말한다') }) test('★ 구멍이 났을 때 커서를 올리지 않는다 — 올리면 그 구간이 영영 안 온다', async () => { const store = makeStore({ streams: { [KEY]: { seq: 4 } } }) await push(store, 7) assert.equal(store.row.liveCursor.streams[KEY].seq, 4) }) test('★ 이미 본 번호는 받아들이되 커서를 뒤로 끌지 않는다 — 재전송이 정상 동작이다', async () => { const store = makeStore({ streams: { [KEY]: { seq: 9 } } }) const out = await push(store, 9) assert.equal(out.status, WEBHOOK_STATUS.ok) assert.equal(store.row.liveCursor.streams[KEY].seq, 9) }) test('★ 반영되지 않았으면 커서를 올리지 않는다 — 트윈이 안 도는 사이의 번호를 지나치면 그 구간이 빈다', async () => { const store = makeStore({ streams: { [KEY]: { seq: 4 } } }) const out = await push(store, 5, { ingest: () => ({ applied: 0, duplicates: 0, rejected: [] }) }) assert.equal(out.status, WEBHOOK_STATUS.notLive) assert.equal(store.row.liveCursor.streams[KEY].seq, 4, '한 건도 안 들어갔는데 번호가 올라갔다') }) test('★ 번호를 매기지 않는 원본은 확인하지 않고, 확인하지 않았다고 말한다', async () => { /* chef · ppms 가 그렇다. 「확인했고 이상 없다」와 같아 보이면 어느 연결이 지켜지는지 알 수 없다. */ const store = makeStore() const out = await push(store, undefined, { scope: undefined }) assert.equal(out.status, WEBHOOK_STATUS.ok) assert.equal(out.body.sequenceChecked, false) assert.equal(store.row.liveCursor, undefined, '번호가 없으면 커서를 만들지 않는다') }) test('★ 번호만 있고 어느 줄인지 없으면 확인하지 않는다 — 짐작한 열쇠는 줄이 늘면 어긋난다', async () => { const store = makeStore() const out = await push(store, 918342, { scope: undefined }) assert.equal(out.status, WEBHOOK_STATUS.ok) assert.equal(out.body.sequenceChecked, false) }) test('★ 줄마다 번호가 따로 간다 — 두 줄이 한 커서에 섞이면 끊임없이 「빠졌다」가 된다', async () => { const store = makeStore() await push(store, 5, { scope: 'EVENTS' }) await push(store, 1, { scope: 'MASTER' }) assert.equal(store.row.liveCursor.streams[`${TWIN}::EVENTS`].seq, 5) assert.equal(store.row.liveCursor.streams[`${TWIN}::MASTER`].seq, 1) /* MASTER 의 1 이 EVENTS 의 5 를 되돌리지 않았으므로 EVENTS 6 이 그대로 이어진다. */ assert.equal((await push(store, 6, { scope: 'EVENTS' })).status, WEBHOOK_STATUS.ok) }) test('★ 재기동을 넘어 이어받는다 — 저장본만 남기고 핸들러를 새로 부른다', async () => { const first = makeStore() await push(first, 5) const saved = JSON.parse(JSON.stringify(first.row.liveCursor)) /* 프로세스가 새로 선 것으로 본다 — 앞의 저장소 객체는 버리고 저장본만 물려준다. */ const second = makeStore(saved) assert.equal((await push(second, 7)).status, WEBHOOK_STATUS.sequenceGap, '이어받지 못하면 7 이 그냥 통과한다') assert.equal((await push(second, 6)).status, WEBHOOK_STATUS.ok) assert.equal(second.row.liveCursor.streams[KEY].seq, 6) }) test('★ 커서를 적지 못해도 이미 들어간 사실을 무르지 않는다 — 다음 번호에서 구멍으로 드러난다', async () => { const store = makeStore() const broken: HookStore = { load: store.load, saveCursor: async () => { throw new Error('disk full') } } const out = await push(broken, 5) assert.equal(out.status, WEBHOOK_STATUS.ok, '유입은 이미 끝났다 — 커서를 못 적은 것이 그것을 무를 이유가 아니다') }) /* ── 한 요청에 봉투 여럿 ──────────────────────────────────────────────────── */ /** 번호가 붙은 봉투 여럿을 한 요청으로 보낸다 — 밀어 주는 쪽은 한 건씩 보내지 않는다. */ function pushBatch(store: HookStore, seqs: number[], opts: { scope?: string; ingest?: any } = {}) { return handleHook({ domainId: DOMAIN, source: SOURCE, instanceId: TWIN, headers: { 'x-twin-hook-secret': SECRET }, body: { scope: 'scope' in opts ? opts.scope : SCOPE, items: seqs.map(seq => ({ seq, record: { moverId: `eq-${seq}`, kind: 'press', status: 'idle' } })) }, nowMs: Date.parse('2026-08-31T02:00:00.000Z'), ingest: opts.ingest ?? applied, store }) } test('★ 배치 가운데가 빠지면 그 앞까지만 받는다 — 구멍 뒤를 받으면 그 구멍이 영영 안 메워진다', async () => { const store = makeStore({ streams: { [KEY]: { seq: 4 } } }) const taken: unknown[][] = [] const out = await pushBatch(store, [5, 6, 9, 10], { ingest: (_id: string, records: unknown[]) => { taken.push(records) return { applied: records.length, duplicates: 0, rejected: [] } } }) assert.equal(taken[0].length, 2, '5·6 만 넣어야 한다') assert.equal(out.status, WEBHOOK_STATUS.sequenceGap) assert.equal(out.body.expectedSeq, 7) assert.equal(out.body.lastSeq, 6, '앞의 것은 받았다는 것을 함께 말한다 — 안 그러면 5·6 을 또 보낸다') assert.equal(store.row.liveCursor.streams[KEY].seq, 6) }) test('★ 배치 전체가 이어지면 끝 번호가 커서가 된다', async () => { const store = makeStore({ streams: { [KEY]: { seq: 4 } } }) const out = await pushBatch(store, [5, 6, 7]) assert.equal(out.status, WEBHOOK_STATUS.ok) assert.equal(out.body.lastSeq, 7) assert.equal(store.row.liveCursor.streams[KEY].seq, 7) }) test('★ 첫 봉투부터 구멍이면 아무것도 넣지 않고 커서도 그대로다', async () => { const store = makeStore({ streams: { [KEY]: { seq: 4 } } }) let called = false const out = await pushBatch(store, [9, 10], { ingest: () => { called = true return { applied: 2, duplicates: 0, rejected: [] } } }) assert.equal(called, false, '받을 것이 없는데 유입을 불렀다') assert.equal(out.status, WEBHOOK_STATUS.sequenceGap) assert.equal(out.body.expectedSeq, 5) assert.equal(store.row.liveCursor.streams[KEY].seq, 4) }) test('★ 번호가 있는 봉투와 없는 봉투를 섞으면 받지 않는다 — 반쯤 확인한 것을 확인했다고 말하지 않는다', async () => { const store = makeStore() const out = await handleHook({ domainId: DOMAIN, source: SOURCE, instanceId: TWIN, headers: { 'x-twin-hook-secret': SECRET }, body: { scope: SCOPE, items: [{ seq: 1, record: {} }, { record: {} }] }, nowMs: Date.parse('2026-08-31T02:00:00.000Z'), ingest: applied, store }) assert.equal(out.status, WEBHOOK_STATUS.badPayload) assert.match(String(out.body.error), /mixed batch/) }) /* ── 일부만 떨어졌을 때 ───────────────────────────────────────────────────── */ const REJECT_ONE = (_id: string, records: unknown[]) => ({ applied: records.length - 1, duplicates: 0, rejected: [{ record: records[records.length - 1], errors: ['equipment: 계약에 없는 필드 — foo'] }] }) test('★ 일부가 떨어지면 422 로 답하고 무엇이 떨어졌는지 함께 준다', async () => { const store = makeStore({ streams: { [KEY]: { seq: 4 } } }) const out = await pushBatch(store, [5, 6], { ingest: REJECT_ONE }) assert.equal(out.status, WEBHOOK_STATUS.partial) assert.equal((out.body.rejected as any[]).length, 1) assert.match(String((out.body.rejected as any[])[0].errors[0]), /계약에 없는 필드/) }) test('★ 떨어진 것이 있어도 다시 보내라고 하지 않는다 — 나머지는 이미 들어갔다', async () => { assert.equal(webhookSenderAction(WEBHOOK_STATUS.partial), 'partial') assert.notEqual(webhookSenderAction(WEBHOOK_STATUS.partial), 'resend-from') }) test('★ 일부가 떨어져도 받은 데까지 커서가 간다 — 안 그러면 그 구간이 매번 다시 온다', async () => { const store = makeStore({ streams: { [KEY]: { seq: 4 } } }) const out = await pushBatch(store, [5, 6], { ingest: REJECT_ONE }) assert.equal(out.body.lastSeq, 6) assert.equal(store.row.liveCursor.streams[KEY].seq, 6) }) test('★ 구멍과 떨어진 것이 함께 나면 한 응답에 둘 다 싣는다', async () => { /* 두 소식이 갈라져 오면 보내는 쪽은 둘 중 하나만 한다. */ const store = makeStore({ streams: { [KEY]: { seq: 4 } } }) const out = await pushBatch(store, [5, 6, 9], { ingest: REJECT_ONE }) assert.equal(out.status, WEBHOOK_STATUS.sequenceGap, '연속성이 먼저다 — 그것이 다음에 무엇을 보낼지 정한다') assert.equal(out.body.expectedSeq, 7) assert.equal(out.body.lastSeq, 6) assert.equal((out.body.rejected as any[]).length, 1, '떨어진 것도 함께 알린다') }) test('전부 통과하면 떨어진 목록이 빈다 — 없는 것과 안 본 것이 같아 보이지 않게', async () => { const store = makeStore() const out = await pushBatch(store, [1, 2]) assert.deepEqual(out.body.rejected, []) }) test('★ 한 연결이 만든 두 트윈이 계수기를 함께 쓰지 않는다', async () => { /* * 커서는 연결마다 한 행에 산다(`TwinReference.liveCursor`). 그런데 연결 하나가 트윈 여럿을 만든다. * 보내는 쪽의 단위 이름만으로 열쇠를 잡으면 두 공장이 한 계수기를 쓰고, B 의 1 이 A 의 5 뒤에서 * 「이미 본 번호」로 버려진다. */ const store = makeStore() const other = 'twin-b' store.row.scopeSpec.produced.push({ siteId: 'site-b', instanceId: other }) const send = (instanceId: string, seq: number) => handleHook({ domainId: DOMAIN, source: SOURCE, instanceId, headers: { 'x-twin-hook-secret': SECRET }, body: { scope: SCOPE, items: [{ seq, record: { moverId: 'eq-1', kind: 'press', status: 'idle' } }] }, nowMs: Date.parse('2026-08-31T02:00:00.000Z'), ingest: applied, store }) await send(TWIN, 5) const first = await send(other, 1) assert.equal(first.status, WEBHOOK_STATUS.ok, 'B 공장의 첫 봉투가 A 공장의 번호에 걸려 버려졌다') assert.equal(store.row.liveCursor.streams[`${TWIN}::${SCOPE}`].seq, 5) assert.equal(store.row.liveCursor.streams[`${other}::${SCOPE}`].seq, 1) assert.equal((await send(TWIN, 6)).status, WEBHOOK_STATUS.ok, 'A 공장이 B 의 번호에 끌려갔다') })