Files
core2026/tools/legacy-db-migration/test/battleResults.test.ts
T
Hide_D c04a93a76c feat: 과거 장수 전투 결과 보존 이관 추가
preserved batres 파일을 기수 단위로 검증·체크포인트하고 과거 장수 상세에 연결한다. check-plan에는 전체 이전 항목과 정보 설명을 함께 노출한다.
2026-08-18 13:15:33 +00:00

178 lines
7.5 KiB
TypeScript

import { mkdir, mkdtemp, rename, rm, writeFile } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import type { Pool as PgPool, PoolClient, QueryResult } from 'pg';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { listBattleResultSeasons, type BattleResultSourceConfig } from '../src/battleResultSource.js';
import { migrateBattleResults } from '../src/battleResults.js';
const temporaryDirectories: string[] = [];
afterEach(async () => {
await Promise.all(
temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true, force: true }))
);
});
const sourceFixture = async (): Promise<BattleResultSourceConfig> => {
const root = await mkdtemp(path.join(os.tmpdir(), 'sammo-battle-results-migrate-'));
temporaryDirectories.push(root);
const season = path.join(root, 'che_190815_w0sU');
await mkdir(season);
await writeFile(path.join(season, 'batres17.txt'), '첫 전투\n둘째 전투\n');
return {
kind: 'local',
directory: root,
identity: { key: 'fixture:che:battle-results', fingerprint: 'b'.repeat(64) },
};
};
const targetPool = (
checkpointRows: Array<Record<string, unknown>> = [],
failPattern?: string
): { pool: PgPool; queries: Array<{ sql: string; values: readonly unknown[] }> } => {
const queries: Array<{ sql: string; values: readonly unknown[] }> = [];
const query = vi.fn(async (sql: string, values: readonly unknown[] = []) => {
queries.push({ sql, values });
if (failPattern && sql.includes(failPattern)) {
failPattern = undefined;
throw new Error('synthetic battle-result write failure');
}
if (sql.includes('FROM "legacy_archive"."battle_result_import_checkpoint"')) {
return { rows: checkpointRows, rowCount: checkpointRows.length } as unknown as QueryResult;
}
if (sql.includes('INSERT INTO "legacy_archive"."battle_result_import_run"')) {
return { rows: [{ id: '91' }], rowCount: 1 } as QueryResult<{ id: string }>;
}
return { rows: [], rowCount: 0 } as unknown as QueryResult;
});
const client = { query, release: vi.fn() } as unknown as PoolClient;
return { pool: { connect: vi.fn(async () => client) } as unknown as PgPool, queries };
};
describe('preserved battle-result migration', () => {
it('rejects an execution identity different from the configured archive source', async () => {
const source = await sourceFixture();
await expect(
migrateBattleResults(targetPool().pool, source, false, 'che', {
mode: 'full',
source: { key: 'different:source', fingerprint: 'c'.repeat(64) },
})
).rejects.toThrow('execution identity does not match');
});
it('imports one immutable season transactionally and checkpoints it', async () => {
const source = await sourceFixture();
const target = targetPool();
const summary = await migrateBattleResults(target.pool, source, true, 'che', {
mode: 'full',
source: source.identity,
});
const sql = target.queries.map((entry) => entry.sql).join('\n');
expect(summary).toMatchObject({
importRunId: '91',
counts: { discoveredSeasons: 1, importedSeasons: 1, importedFiles: 1, importedLines: 2 },
});
expect(sql).toContain('INSERT INTO "legacy_archive"."general_battle_result"');
expect(sql).toContain('DELETE FROM "legacy_archive"."general_battle_result"');
expect(sql).toContain('INSERT INTO "legacy_archive"."battle_result_import_checkpoint"');
expect(target.queries.some((entry) => entry.sql === 'BEGIN')).toBe(true);
expect(target.queries.some((entry) => entry.sql === 'COMMIT')).toBe(true);
});
it('skips an unchanged checkpoint during incremental import', async () => {
const source = await sourceFixture();
const [manifest] = await listBattleResultSeasons(source, 'che');
const target = targetPool([
{
source_fingerprint: source.identity.fingerprint,
server_id: manifest!.serverId,
manifest_hash: manifest!.manifestHash,
file_count: manifest!.fileCount,
total_bytes: String(manifest!.totalBytes),
},
]);
const summary = await migrateBattleResults(target.pool, source, false, 'che', {
mode: 'incremental',
source: source.identity,
});
expect(summary.counts).toMatchObject({ unchangedSeasons: 1, pendingSeasons: 0, importedFiles: 0 });
expect(target.queries.some((entry) => entry.sql.includes('general_battle_result'))).toBe(false);
});
it('reuses manifests collected by plan preflight instead of scanning the source twice', async () => {
const source = await sourceFixture();
const manifests = await listBattleResultSeasons(source, 'che');
await rename(source.directory, `${source.directory}-moved`);
temporaryDirectories.push(`${source.directory}-moved`);
const target = targetPool();
const summary = await migrateBattleResults(
target.pool,
source,
false,
'che',
{ mode: 'full', source: source.identity },
manifests
);
expect(summary.counts).toMatchObject({ discoveredSeasons: 1, pendingSeasons: 1 });
});
it('rejects changed checkpointed content in incremental mode', async () => {
const source = await sourceFixture();
const [manifest] = await listBattleResultSeasons(source, 'che');
const target = targetPool([
{
source_fingerprint: source.identity.fingerprint,
server_id: manifest!.serverId,
manifest_hash: 'c'.repeat(64),
file_count: manifest!.fileCount,
total_bytes: String(manifest!.totalBytes),
},
]);
await expect(
migrateBattleResults(target.pool, source, false, 'che', {
mode: 'incremental',
source: source.identity,
})
).rejects.toThrow('changed after checkpoint');
});
it('rejects a disappeared checkpointed season in full mode instead of leaving stale target rows', async () => {
const source = await sourceFixture();
const target = targetPool([
{
source_fingerprint: source.identity.fingerprint,
server_id: 'che_180101_missing',
manifest_hash: 'c'.repeat(64),
file_count: 1,
total_bytes: '10',
},
]);
await expect(
migrateBattleResults(target.pool, source, false, 'che', {
mode: 'full',
source: source.identity,
})
).rejects.toThrow('seasons disappeared from the source');
});
it('rolls back the current season and records a failed run', async () => {
const source = await sourceFixture();
const target = targetPool([], 'general_battle_result');
await expect(
migrateBattleResults(target.pool, source, true, 'che', { mode: 'full', source: source.identity })
).rejects.toThrow('synthetic battle-result write failure');
expect(target.queries.some((entry) => entry.sql === 'ROLLBACK')).toBe(true);
expect(target.queries.some((entry) => entry.sql.includes(`"status" = 'FAILED'`))).toBe(true);
});
});