feat: 과거 장수 전투 결과 보존 이관 추가
preserved batres 파일을 기수 단위로 검증·체크포인트하고 과거 장수 상세에 연결한다. check-plan에는 전체 이전 항목과 정보 설명을 함께 노출한다.
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
+1
@@ -0,0 +1 @@
|
||||
이 페이즈 상세 로그는 이관하면 안 된다.
|
||||
+2
@@ -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 페이즈 상세는 제외'),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user