/* * **놓친 구간을 backfill 해 채운다** — 그 길이 없었다. * * ── 무엇이 있었나 (2026-08-31, 인티그레이션 레인 지적) ──────────────────── * 번호로 「빠졌다」를 알아채게 만들었는데, 알아챈 뒤 **그 사이를 채우는 길이 없었다.** 설계가 적어 둔 * 길이고 양쪽이 다 읽었는데 만들지 않았다. * * ── 되풀이해 요청하는 경로에서 무너지는 것 ───────────────────────────────────── * 행복한 길은 짧다. 무너지는 것은 **끝나지 않는 것**이다. * * 빈 답에 물어본 번호를 그대로 돌려주는 원본 → 무한히 요청한다 * 구멍 뒤를 이어 받는다 → 커서가 그 번호를 지나쳐 적히고 빠진 사실은 안 온다 * 상한에 닿았는데 조용히 끝낸다 → 사람은 다 받은 줄로 읽는다 * * 셋 다 오류가 나지 않는다. 그래서 시험이 셋을 만든다. */ import { test } from 'node:test' import assert from 'node:assert/strict' import { fillSince } from '../server/service/reference/reference-fill.js' import { pushCursorKey, seqOf } from '../server/service/reference/hook-contract.js' import type { InboundBatch } from '../server/service/reference/reference-adapter.js' const CFG = { baseUrl: 'x' } const SITE = { siteId: 's-1' } const SCOPE = 'EVENTS' const KEY = pushCursorKey('twin-a', SCOPE) /** 번호가 붙은 봉투 한 쪽. */ const page = (seqs: number[], scope: string = SCOPE): InboundBatch => ({ scope, items: seqs.map(seq => ({ seq, record: { moverId: `eq-${seq}`, kind: 'press', status: 'idle' } })) }) /** 저장본 하나 — 프로세스가 아니라 여기 산다. */ function harness(initial?: unknown) { const state = { cursor: initial as unknown, saves: 0, ingested: [] as unknown[][] } return { state, saveCursor: async (c: Record) => { state.saves++ state.cursor = JSON.parse(JSON.stringify(c)) }, ingest: (records: unknown[]) => { state.ingested.push(records) return { applied: records.length, duplicates: 0, rejected: [] } } } } const run = (h: ReturnType, fetch: any, maxPages?: number) => fillSince({ cfg: CFG, site: SITE, scope: SCOPE, cursorKey: KEY, cursor: h.state.cursor, fetch, ingest: h.ingest, saveCursor: h.saveCursor, ...(maxPages ? { maxPages } : {}) }) /* ── 채운다 ───────────────────────────────────────────────────────────────── */ test('★ 커서 다음부터 요청하고 받은 데까지 커서를 올린다', async () => { const h = harness({ streams: { [KEY]: { seq: 4 } } }) const asked: number[] = [] const out = await run(h, async (_c: any, _s: any, _sc: string, since: number) => { asked.push(since) return since === 4 ? page([5, 6]) : page([]) }) assert.deepEqual(asked, [4, 6], '받은 데부터 다시 요청한다') assert.equal(out.applied, 2) assert.equal(out.stopped, 'done') assert.equal(seqOf(h.state.cursor, KEY), 6) }) test('★ 커서가 없으면 0부터 요청한다. 처음 채우는 것이다', async () => { const h = harness() const asked: number[] = [] await run(h, async (_c: any, _s: any, _sc: string, since: number) => { asked.push(since) return since === 0 ? page([1, 2, 3]) : page([]) }) assert.equal(asked[0], 0) assert.equal(seqOf(h.state.cursor, KEY), 3) }) test('여러 쪽을 이어 받는다', async () => { const h = harness() const out = await run(h, async (_c: any, _s: any, _sc: string, since: number) => { if (since === 0) return page([1, 2]) if (since === 2) return page([3, 4]) return page([]) }) assert.equal(out.applied, 4) assert.equal(out.pages >= 2, true) assert.equal(seqOf(h.state.cursor, KEY), 4) }) /* ── 끝나지 않는 것을 막는다 ──────────────────────────────────────────────── */ test('★ 번호가 나아가지 않으면 멈춘다 — 같은 것을 다시 주는 원본에 무한히 요청하지 않는다', async () => { /* * 아웃박스 계약이 「빈 묶음에는 물어본 번호를 그대로 돌려준다」이므로, 나아감을 보지 않고 요청하면 * 영원히 돈다. 실제로 그 계약을 쓰는 상대가 있다. */ const h = harness({ streams: { [KEY]: { seq: 4 } } }) let calls = 0 const out = await run(h, async () => { calls++ /* 이미 받은 번호만 돌려준다 — 커서가 나아가지 않는다. */ return page([3, 4]) }) assert.equal(out.stopped, 'no-progress') assert.ok(calls <= 2, `무한히 요청했다 — ${calls}번`) assert.match(String(out.detail), /나아가지 않았다/) }) test('★ 빈 답은 끝이고 오류가 아니다', async () => { const h = harness({ streams: { [KEY]: { seq: 9 } } }) const out = await run(h, async () => page([])) assert.equal(out.stopped, 'done') assert.equal(out.applied, 0) assert.equal(out.detail, undefined) }) /* ── 구멍 ─────────────────────────────────────────────────────────────────── */ test('★ 구멍이 있으면 그 앞까지만 넣고 멈춘다 — 이어 받으면 빠진 사실이 다시 오지 않는다', async () => { const h = harness({ streams: { [KEY]: { seq: 4 } } }) const out = await run(h, async (_c: any, _s: any, _sc: string, since: number) => since === 4 ? page([5, 6, 9, 10]) : page([]) ) assert.equal(out.stopped, 'gap') assert.equal(out.applied, 2, '5·6 만 넣는다') assert.equal(seqOf(h.state.cursor, KEY), 6, '커서가 구멍을 지나치지 않는다') assert.match(String(out.detail), /6 다음이 7/) }) test('★ 첫 봉투부터 구멍이면 아무것도 넣지 않고 커서도 그대로다', async () => { const h = harness({ streams: { [KEY]: { seq: 4 } } }) const out = await run(h, async () => page([9, 10])) assert.equal(out.stopped, 'gap') assert.equal(out.applied, 0) assert.equal(seqOf(h.state.cursor, KEY), 4) assert.equal(h.state.ingested.length, 0, '넣을 것이 없는데 유입을 불렀다') }) /* ── 상한 ─────────────────────────────────────────────────────────────────── */ test('★ 쪽 상한에 닿으면 말한다 — 조용히 자르면 사람은 다 받은 줄로 읽는다', async () => { const h = harness() let n = 0 const out = await run(h, async () => page([++n]), 3) assert.equal(out.stopped, 'page-limit') assert.equal(out.pages, 3) assert.match(String(out.detail), /남은 것이 있을 수 있다/) }) /* ── 거절 ─────────────────────────────────────────────────────────────────── */ test('★ backfill 이 실패하면 이유를 남기고 멈춘다 — 커서는 받은 데까지다', async () => { const h = harness({ streams: { [KEY]: { seq: 4 } } }) const out = await run(h, async (_c: any, _s: any, _sc: string, since: number) => { if (since === 4) return page([5]) throw new Error('MES 가 응답하지 않는다') }) assert.equal(out.stopped, 'refused') assert.match(String(out.detail), /응답하지 않는다/) assert.equal(seqOf(h.state.cursor, KEY), 5, '받은 것은 지킨다') }) test('★ 번호가 섞인 쪽은 받지 않는다 — 밀어 받는 길과 같은 검사를 지난다', async () => { const h = harness() const out = await run(h, async () => ({ scope: SCOPE, items: [{ seq: 1, record: {} }, { record: {} }] })) assert.equal(out.stopped, 'refused') assert.match(String(out.detail), /mixed batch/) assert.equal(h.state.ingested.length, 0) }) test('★ 커서 열쇠에 트윈이 들어간다 — 한 연결의 두 공장이 계수기를 함께 쓰지 않는다', async () => { const other = pushCursorKey('twin-b', SCOPE) const h = harness({ streams: { [KEY]: { seq: 4 }, [other]: { seq: 100 } } }) await run(h, async (_c: any, _s: any, _sc: string, since: number) => (since === 4 ? page([5]) : page([]))) assert.equal(seqOf(h.state.cursor, KEY), 5) assert.equal(seqOf(h.state.cursor, other), 100, '다른 공장의 번호를 건드렸다') }) /* ── 원본이 가진 번호 범위 ──────────────────────────────────────────────────── */ test('★ 원본이 말한 번호 범위를 그대로 낸다 — 커서와 견주려면 이 값이 있어야 한다', async () => { const got = await fillSince({ cfg: {}, site: { siteId: 's' }, scope: 'EVENTS', cursorKey: 'i::EVENTS', cursor: {}, fetch: async () => ({ items: [{ seq: 1, record: {} }], scope: 'EVENTS', sourceRange: { first: 1, last: 20 } }), ingest: () => ({ applied: 1, duplicates: 0, rejected: [] }), saveCursor: async () => {} }) assert.deepEqual(got.sourceRange, { first: 1, last: 20 }) }) test('★ 원본이 안 말하면 그 칸을 안 낸다 — 0 으로 채우면 「다 받았다」가 된다', async () => { /* * 0 을 넣으면 「원본이 1번부터 다 갖고 있다」로 읽히고, 그것이 거짓이면 「우리가 다 받았다」는 * 잘못된 결론이 나온다. 견주는 쪽은 「맞다」가 아니라 「모른다」로 답해야 한다. */ const got = await fillSince({ cfg: {}, site: { siteId: 's' }, scope: 'EVENTS', cursorKey: 'i::EVENTS', cursor: {}, fetch: async () => ({ items: [{ seq: 1, record: {} }], scope: 'EVENTS' }), ingest: () => ({ applied: 1, duplicates: 0, rejected: [] }), saveCursor: async () => {} }) assert.equal(got.sourceRange, undefined) assert.equal('sourceRange' in got, false, '없는 것을 undefined 로 실으면 화면이 칸을 그린다') })