/* * ★ 저장된 값을 시각으로 읽는 규칙 — 드라이버의 문을 지난다. * * ── 무엇이 어긋났나 (2026-08-28 실측) ─────────────────────────────────────── * `getRawOne` 은 드라이버가 준 값을 그대로 준다. sqlite 가 주는 그 값에는 시간대가 없다 * (`2026-08-27 14:37:20.049`). 그것을 `new Date()` 로 읽으면 서버의 지역 시간으로 해석해 한국에서 * 9시간이 밀린다. 네 자리에서 났고, 둘은 **지우는 경계**였다(경계가 이르게 밀리면 정리가 0건이 되어 * 멈춘다). * * ── 처음에 편법을 썼다 ────────────────────────────────────────────────────── * 「시간대가 없으면 UTC 로 읽는다」를 응용 코드에 직접 적었다. 그것은 드라이버가 어떤 문자열로 * 저장하는지를 우리가 아는 것이고, 다섯 드라이버 규칙에 어긋난다. * * 표준 문은 `Driver.prepareHydratedValue` 다 — 엔티티 변환이 지나는 그 함수. 이 시험은 컬럼 값을 * 읽을 때 **우리가 해석하지 않고 그 문에 물어보는지**를 지킨다. */ import { test } from 'node:test' import assert from 'node:assert/strict' import { readFileSync, readdirSync, statSync } from 'node:fs' import { join } from 'node:path' import { absoluteTime, hydratedDate } from '../server/engine/read-time.js' /** 드라이버를 흉내낸 저장소 — 이 시험이 확인하는 것은 「누가 해석하나」다. */ const fakeRepository = (opts: { hasColumn?: boolean; hydrate?: (v: unknown) => unknown } = {}) => { const calls: unknown[] = [] const repo = { metadata: { name: 'TwinEvent', findColumnWithPropertyName: (name: string) => (opts.hasColumn === false ? undefined : { propertyName: name, type: Date }) }, manager: { connection: { driver: { prepareHydratedValue: (value: unknown) => { calls.push(value) return opts.hydrate ? opts.hydrate(value) : new Date('2026-08-27T14:37:20.049Z') } } } } } return { repo: repo as any, calls } } test('★ 컬럼 값은 우리가 해석하지 않고 드라이버에게 물어본다', () => { const { repo, calls } = fakeRepository() const at = hydratedDate(repo, 'createdAt', '2026-08-27 14:37:20.049') assert.equal(at?.toISOString(), '2026-08-27T14:37:20.049Z') assert.deepEqual(calls, ['2026-08-27 14:37:20.049'], '드라이버의 문을 그 값으로 한 번 지나야 한다') }) test('빈 값은 드라이버를 부르지 않고 null — 없는 것은 없는 것이다', () => { const { repo, calls } = fakeRepository() assert.equal(hydratedDate(repo, 'createdAt', null), null) assert.equal(hydratedDate(repo, 'createdAt', undefined), null) assert.deepEqual(calls, []) }) test('드라이버가 날짜를 내주지 못하면 null — 「지금」으로 메우지 않는다', () => { const { repo } = fakeRepository({ hydrate: () => 'still a string' }) assert.equal(hydratedDate(repo, 'createdAt', 'x'), null) const bad = fakeRepository({ hydrate: () => new Date('nope') }) assert.equal(hydratedDate(bad.repo, 'createdAt', 'x'), null) }) test('★ 컬럼 이름을 못 찾으면 명시 실패한다 — 이름이 바뀐 것을 값으로 덮지 않는다', () => { const { repo } = fakeRepository({ hasColumn: false }) assert.throws(() => hydratedDate(repo, 'createdAt', '2026-08-27 14:37:20.049'), /no column for property/) }) test('★ 계약이 절대시각인 값 — 시간대가 없으면 UTC 다(지역 시간으로 읽을 근거가 없다)', () => { assert.equal(absoluteTime('2026-08-27 14:37:20.049')?.toISOString(), '2026-08-27T14:37:20.049Z') /* 지역 시간으로 읽었다면 한국에서 9시간이 밀린다. */ assert.notEqual(absoluteTime('2026-08-27 14:37:20.049')?.toISOString(), '2026-08-27T05:37:20.049Z') }) test('시간대가 적혀 있으면 그 말을 믿는다', () => { assert.equal(absoluteTime('2026-08-27T14:37:20.049Z')?.toISOString(), '2026-08-27T14:37:20.049Z') assert.equal(absoluteTime('2026-08-27T23:37:20.049+09:00')?.toISOString(), '2026-08-27T14:37:20.049Z') assert.equal(absoluteTime('2026-08-27T09:37:20.049-05:00')?.toISOString(), '2026-08-27T14:37:20.049Z') }) test('날짜 객체와 밀리초도 받는다', () => { const d = new Date('2026-08-27T14:37:20.049Z') assert.equal(absoluteTime(d), d) assert.equal(absoluteTime(1_756_305_440_049)?.getTime(), 1_756_305_440_049) }) test('★ 읽을 수 없는 값은 null — 경계를 정하는 자리가 이 함수를 쓴다', () => { for (const bad of [null, undefined, '', ' ', '어제', 'not-a-date', {}, [], NaN, new Date('x')]) { assert.equal(absoluteTime(bad), null, `${String(bad)} 는 null 이어야 한다`) } }) test('★ 컬럼 값을 직접 날짜로 만드는 자리가 남아 있지 않다', () => { /* * 규칙이 있어도 새로 쓰는 코드가 그것을 지나지 않으면 같은 결함이 다시 난다. 그래서 자리를 세어 * 둔다 — 원시 결과의 컬럼을 `new Date(...)` 로 감싸는 모양을 찾는다. * * 밀리초 숫자(`new Date(someMs)`)는 시간대가 없으므로 이 그물에 걸리지 않는다. */ const root = join(import.meta.dirname, '..', 'server') const offenders: string[] = [] const walk = (dir: string) => { for (const name of readdirSync(dir)) { const p = join(dir, name) if (statSync(p).isDirectory()) { walk(p); continue } if (!name.endsWith('.ts')) continue const text = readFileSync(p, 'utf8') for (const m of text.matchAll(/new Date\(\s*[A-Za-z_$][\w$?.]*\.(eventTime|createdAt|updatedAt|at)\b[^)]*\)/g)) { offenders.push(`${name}: ${m[0]}`) } } } walk(root) assert.deepEqual(offenders, [], '컬럼 값을 직접 날짜로 만드는 자리가 있다 — hydratedDate() 를 지나야 한다') })