/* * ★ 연결된 시스템에 닿지 못한 것과 다시 닿은 것을 인제스트 레저가 기록한다. * * ── 무엇이 없었나 (2026-08-27) ────────────────────────────────────────────── * 커넥터는 폴링이 실패할 때 세 가지를 안다: 연달아 몇 번인지 · 언제부터인지 · 다음에 언제 다시 묻는지. * 그런데 어댑터 계약에는 이유 한 줄만 받는 자리가 있었다. 그래서 커넥터가 세 가지를 **한국어 문장으로 * 합쳐** 이유 칸에 넣고 있었다(`ppms-sunsage.ts`). * * 두 가지가 틀어진다. * ① 연달아 횟수와 시작 시각은 이 레저가 **이미 센다**. 두 곳에서 세면 갈라진다. * ② 그 칸은 화면에 그려지고 화면은 다섯 언어로 그린다. 한 언어의 문장은 번역되지 않는다. * * 그리고 회복을 받는 자리가 아예 없었다. 읽기가 다시 성공하면 실패 기록만 지워졌고, 그러면 밤새 두 시간 * 끊겼던 연결과 한 번도 끊기지 않은 연결이 아침에 똑같이 보인다. */ import { test } from 'node:test' import assert from 'node:assert/strict' import { newIngestLedger, recordReadFailure, recordRecovered, clearReadFailure, ingestHealth } from '../server/engine/ingest-health.js' const t0 = Date.UTC(2026, 7, 27, 3, 0, 0) test('★ 연달아 실패한 횟수와 시작 시각은 레저가 센다 — 커넥터가 보내지 않는다', () => { const l = newIngestLedger() recordReadFailure(l, 'connect timeout', t0, 'inverters') recordReadFailure(l, 'connect timeout', t0 + 60_000, 'inverters') recordReadFailure(l, 'connect timeout', t0 + 120_000, 'inverters') assert.equal(l.readFailure?.consecutive, 3) assert.equal(l.readFailure?.sinceMs, t0, '처음 실패한 시각을 붙잡는다') assert.equal(l.readFailure?.lastAtMs, t0 + 120_000) }) test('★ 이유는 이유만 담는다 — 세는 값을 문장으로 합쳐 넣지 않는다', () => { /* * 이 테스트는 사람이 읽는 문장이 이유 칸에 들어가는 것을 막지 못한다(문자열은 무엇이든 담긴다). * 막을 수 있는 것은 **그 값들을 담을 다른 자리가 있다**는 사실뿐이다. 그것을 테스트로 고정한다. */ const l = newIngestLedger() recordReadFailure(l, 'connect timeout', t0, 'inverters', 480_000) assert.equal(l.readFailure?.reason, 'connect timeout') assert.equal(l.readFailure?.nextRetryMs, 480_000) }) test('다음 시도까지의 시간은 있을 때만 적는다 — 0 이나 없는 값을 「곧 다시 묻는다」로 만들지 않는다', () => { const l = newIngestLedger() recordReadFailure(l, 'connect timeout', t0) assert.equal('nextRetryMs' in (l.readFailure as any), false) recordReadFailure(l, 'connect timeout', t0 + 1000, undefined, 0) assert.equal('nextRetryMs' in (l.readFailure as any), false) }) test('★ 돌아오면 실패 기록이 사라지고 돌아온 사실이 남는다', () => { const l = newIngestLedger() recordReadFailure(l, 'connect timeout', t0) recordReadFailure(l, 'connect timeout', t0 + 60_000) recordRecovered(l, 2, 120_000, t0 + 120_000) assert.equal(l.readFailure, undefined, '지금은 닿는다') assert.deepEqual(l.lastRecovery, { atMs: t0 + 120_000, afterFailures: 2, downMs: 120_000 }) }) test('★ 그냥 지우면 끊긴 적이 있었다는 사실이 사라진다 — 그래서 두 함수를 나눈다', () => { const l = newIngestLedger() recordReadFailure(l, 'connect timeout', t0) clearReadFailure(l) assert.equal(l.readFailure, undefined) assert.equal(l.lastRecovery, undefined, '지우기만 하면 남지 않는다') }) test('★ 다시 끊기면 새 단절이다 — 지난번 회복 기록이 그것을 가리지 않는다', () => { const l = newIngestLedger() recordReadFailure(l, 'connect timeout', t0) recordRecovered(l, 1, 60_000, t0 + 60_000) recordReadFailure(l, '401 unauthorized', t0 + 600_000) assert.equal(l.readFailure?.consecutive, 1, '연달아 횟수가 1 부터 다시 센다') assert.equal(l.readFailure?.sinceMs, t0 + 600_000) assert.equal(l.lastRecovery?.atMs, t0 + 60_000, '지난 회복도 함께 보인다') }) test('★ 밖으로 내는 모양에 두 사실이 다 있다 — 화면이 옮기지 못하면 아무 도움이 되지 않는다', () => { const l = newIngestLedger() recordReadFailure(l, 'connect timeout', t0, 'inverters', 480_000) const v: any = ingestHealth(l, 'attached') assert.equal(v.readFailure.consecutive, 1) assert.equal(v.readFailure.reason, 'connect timeout') assert.equal(v.readFailure.stream, 'inverters') assert.equal(v.readFailure.nextRetryMs, 480_000) assert.equal(typeof v.readFailure.since, 'string') recordRecovered(l, 1, 480_000, t0 + 480_000) const w: any = ingestHealth(l, 'attached') assert.equal(w.readFailure, undefined) assert.equal(w.lastRecovery.at, new Date(t0 + 480_000).toISOString()) assert.equal(w.lastRecovery.afterFailures, 1) assert.equal(w.lastRecovery.downMs, 480_000) }) test('닿은 적도 끊긴 적도 없으면 두 칸이 다 없다 — 없는 칸을 「끊겼다」로 읽지 않게', () => { const v: any = ingestHealth(newIngestLedger(), 'attached') assert.equal('readFailure' in v, false) assert.equal('lastRecovery' in v, false) })