feat: 과거 장수 전투 결과 보존 이관 추가

preserved batres 파일을 기수 단위로 검증·체크포인트하고 과거 장수 상세에 연결한다. check-plan에는 전체 이전 항목과 정보 설명을 함께 노출한다.
This commit is contained in:
2026-08-18 13:15:33 +00:00
parent 2c4d52208b
commit c04a93a76c
20 changed files with 1359 additions and 51 deletions
@@ -0,0 +1,69 @@
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { afterEach, describe, expect, it } from 'vitest';
import {
listBattleResultSeasons,
readBattleResultSeason,
resolveBattleResultSourceConfig,
} from '../src/battleResultSource.js';
const temporaryDirectories: string[] = [];
afterEach(async () => {
await Promise.all(
temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true, force: true }))
);
});
const fixture = async () => {
const root = await mkdtemp(path.join(os.tmpdir(), 'sammo-battle-results-'));
temporaryDirectories.push(root);
const season = path.join(root, 'che_190815_w0sU');
await mkdir(season);
await writeFile(path.join(season, 'batres17.txt'), '<S>◆</>190년 1월:첫 전투\n<S>◆</>190년 2월:둘째 전투\n');
await writeFile(path.join(season, 'batlog17.txt'), '페이즈 상세는 제외\n');
await mkdir(path.join(root, 'not-a-season'));
return { root, season };
};
describe('preserved battle-result source', () => {
it('maps only batres files by profile/server/general and preserves source text', async () => {
const { root } = await fixture();
const source = await resolveBattleResultSourceConfig({ directory: root }, 'fixture:che', 'che', process.cwd());
const seasons = await listBattleResultSeasons(source, 'che');
expect(seasons).toHaveLength(1);
expect(seasons[0]).toMatchObject({ serverId: 'che_190815_w0sU', fileCount: 1 });
expect(seasons[0]?.files[0]).toMatchObject({ generalNo: 17 });
const loaded = await readBattleResultSeason(source, 'che', 'che_190815_w0sU');
expect(loaded.manifest.manifestHash).toBe(seasons[0]?.manifestHash);
expect(loaded.files).toHaveLength(1);
expect(loaded.files[0]).toMatchObject({ generalNo: 17, lineCount: 2 });
expect(loaded.files[0]?.content).toContain('둘째 전투');
});
it('builds a password-free source identity for the configured SSH location', async () => {
const source = await resolveBattleResultSourceConfig(
{ directory: '/srv/sammo/che/logs/preserved', sshHost: 'serv' },
'cutover:che',
'che',
process.cwd()
);
expect(source).toMatchObject({ kind: 'ssh', sshHost: 'serv' });
expect(source.identity.key).toBe('cutover:che:battle-results');
expect(source.identity.fingerprint).toMatch(/^[a-f0-9]{64}$/u);
});
it('rejects invalid UTF-8 during preflight instead of failing after target writes begin', async () => {
const { root, season } = await fixture();
await writeFile(path.join(season, 'batres18.txt'), Buffer.from([0xff]));
const source = await resolveBattleResultSourceConfig({ directory: root }, 'fixture:che', 'che', process.cwd());
await expect(listBattleResultSeasons(source, 'che')).rejects.toThrow();
});
});
@@ -0,0 +1,177 @@
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);
});
});
@@ -0,0 +1 @@
이 페이즈 상세 로그는 이관하면 안 된다.
@@ -0,0 +1,2 @@
189년 4월: 테스트 장수 승리
189년 3월: 테스트 장수 패배
@@ -0,0 +1,57 @@
import { describe, expect, it } from 'vitest';
import type { ResolvedMigrationStage } from '../src/config.js';
import { migrationInventoryForStage } from '../src/inventory.js';
const gameStage = (withBattleResults: boolean): ResolvedMigrationStage => ({
kind: 'game',
name: 'che',
profile: 'che',
sourceUrl: 'mariadb://source.invalid/che',
targetUrl: 'postgresql://target.invalid/game',
sourceIdentity: { key: 'fixture:che', fingerprint: 'a'.repeat(64) },
...(withBattleResults
? {
battleResults: {
kind: 'ssh' as const,
directory: '/srv/sammo/che/logs/preserved',
sshHost: 'serv',
identity: { key: 'fixture:che:battle-results', fingerprint: 'b'.repeat(64) },
},
}
: {}),
});
describe('migration plan inventory', () => {
it('lists each gateway item with its transferred information', () => {
const inventory = migrationInventoryForStage({
kind: 'gateway',
name: 'gateway',
sourceUrl: 'mariadb://source.invalid/root',
targetUrl: 'postgresql://target.invalid/gateway',
sourceIdentity: { key: 'fixture:gateway', fingerprint: 'a'.repeat(64) },
});
expect(inventory.map((item) => item.source)).toEqual([
'member',
'member_log',
'banned_member',
'storage',
'system',
]);
expect(inventory.every((item) => item.contents.length > 0)).toBe(true);
});
it('lists batres only when that filesystem source is configured', () => {
expect(migrationInventoryForStage(gameStage(false)).some((item) => item.strategy === 'filesystem-season')).toBe(
false
);
expect(migrationInventoryForStage(gameStage(true))).toContainEqual(
expect.objectContaining({
source: 'logs/preserved/<server_id>/batres<general_no>.txt',
strategy: 'filesystem-season',
contents: expect.stringContaining('batlog 페이즈 상세는 제외'),
})
);
});
});