feat: 레거시 재이관과 계정 복구 기반을 추가
중앙 이전 기록 스키마와 버전 정규화기를 도입하고, 재실행 시 현행 계정 상태를 보존한다. 카카오 인증 뒤 이관 비밀번호를 1회 설정하는 흐름과 비카카오 계정용 안전한 CLI 복구 경로를 추가한다.
This commit is contained in:
@@ -0,0 +1,146 @@
|
||||
import type { Pool as MariaPool } from 'mariadb';
|
||||
import type { Pool as PgPool, PoolClient, QueryResult } from 'pg';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { isLegacyArchiveProfile, migrateGame, resolveLegacyGameOpenedAt } from '../src/game.js';
|
||||
|
||||
const sourceRows = {
|
||||
ng_games: [
|
||||
{
|
||||
id: 1,
|
||||
server_id: 'che_fixture_001',
|
||||
date: new Date('2020-01-01T00:00:00.000Z'),
|
||||
winner_nation: 3,
|
||||
map: 'che',
|
||||
season: 1,
|
||||
scenario: 2,
|
||||
scenario_name: 'fixture',
|
||||
env: JSON.stringify({ opentime: '2020-01-02T00:00:00.000Z', starttime: '2020-01-03T00:00:00.000Z' }),
|
||||
},
|
||||
],
|
||||
ng_old_generals: [
|
||||
{
|
||||
id: 2,
|
||||
server_id: 'che_fixture_001',
|
||||
general_no: 10,
|
||||
owner: 42,
|
||||
name: 'fixture-general',
|
||||
last_yearmonth: 22012,
|
||||
turntime: new Date('2020-02-01T00:00:00.000Z'),
|
||||
data: JSON.stringify({ leader: 80, power: 70, intel: 60, history: 'first<br>second<br>' }),
|
||||
},
|
||||
],
|
||||
} satisfies Record<string, Array<Record<string, unknown>>>;
|
||||
|
||||
const sourcePool = (): MariaPool => {
|
||||
const seen = new Set<string>();
|
||||
return {
|
||||
query: vi.fn(async (sql: string) => {
|
||||
const table = /FROM `([a-z_]+)`/u.exec(sql)?.[1] ?? '';
|
||||
if (seen.has(table)) return [];
|
||||
seen.add(table);
|
||||
return sourceRows[table as keyof typeof sourceRows] ?? [];
|
||||
}),
|
||||
} as unknown as MariaPool;
|
||||
};
|
||||
|
||||
const targetPool = (failPattern?: string) => {
|
||||
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 archive write failure');
|
||||
}
|
||||
if (sql.includes('INSERT INTO "legacy_archive"."import_run"')) {
|
||||
return { rows: [{ id: '77' }], 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('legacy archive game migration', () => {
|
||||
it('uses the official profile allowlist and resolves the best opening timestamp', () => {
|
||||
expect(isLegacyArchiveProfile('che')).toBe(true);
|
||||
expect(isLegacyArchiveProfile('hwe')).toBe(true);
|
||||
expect(isLegacyArchiveProfile('custom')).toBe(false);
|
||||
expect(
|
||||
resolveLegacyGameOpenedAt(
|
||||
{ opentime: '2020-01-02T00:00:00.000Z', starttime: '2020-01-03T00:00:00.000Z' },
|
||||
new Date('2020-01-01T00:00:00.000Z'),
|
||||
'fixture'
|
||||
).toISOString()
|
||||
).toBe('2020-01-02T00:00:00.000Z');
|
||||
expect(
|
||||
resolveLegacyGameOpenedAt(
|
||||
{ starttime: '2020-01-03T00:00:00.000Z' },
|
||||
new Date('2020-01-01T00:00:00.000Z'),
|
||||
'fixture'
|
||||
).toISOString()
|
||||
).toBe('2020-01-03T00:00:00.000Z');
|
||||
expect(resolveLegacyGameOpenedAt({}, new Date('2020-01-01T00:00:00.000Z'), 'fixture').toISOString()).toBe(
|
||||
'2020-01-01T00:00:00.000Z'
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps dry-run target-read-only while reporting normalized formats', async () => {
|
||||
const summary = await migrateGame(sourcePool(), null, false, 'che');
|
||||
expect(summary).toMatchObject({
|
||||
apply: false,
|
||||
importRunId: null,
|
||||
counts: { ng_games: 1, ng_old_generals: 1 },
|
||||
sourceFormatSummary: { 'legacy-flat-v0': 1 },
|
||||
});
|
||||
});
|
||||
|
||||
it('records a completed import run and writes only archive tables for historical snapshots', async () => {
|
||||
const target = targetPool();
|
||||
const summary = await migrateGame(sourcePool(), target.pool, true, 'che');
|
||||
const sql = target.queries.map((entry) => entry.sql).join('\n');
|
||||
|
||||
expect(summary.importRunId).toBe('77');
|
||||
expect(sql).toContain('INSERT INTO "legacy_archive"."game_history"');
|
||||
expect(sql).toContain('INSERT INTO "legacy_archive"."general"');
|
||||
expect(sql).not.toContain('INSERT INTO "ng_games"');
|
||||
expect(sql).not.toContain('INSERT INTO "ng_old_generals"');
|
||||
expect(sql).toContain(`SET "status" = 'COMPLETED'`);
|
||||
expect(target.queries.some((entry) => entry.sql === 'BEGIN')).toBe(true);
|
||||
expect(target.queries.some((entry) => entry.sql === 'COMMIT')).toBe(true);
|
||||
expect(target.queries.findIndex((entry) => entry.sql.includes(`SET "status" = 'COMPLETED'`))).toBeLessThan(
|
||||
target.queries.findIndex((entry) => entry.sql === 'COMMIT')
|
||||
);
|
||||
});
|
||||
|
||||
it('rolls back archive writes and records a failed import run', async () => {
|
||||
const target = targetPool('INSERT INTO "legacy_archive"."general"');
|
||||
await expect(migrateGame(sourcePool(), target.pool, true, 'che')).rejects.toThrow(
|
||||
'synthetic archive write failure'
|
||||
);
|
||||
const sql = target.queries.map((entry) => entry.sql).join('\n');
|
||||
expect(target.queries.some((entry) => entry.sql === 'ROLLBACK')).toBe(true);
|
||||
expect(sql).toContain(`SET "status" = 'FAILED'`);
|
||||
expect(sql).not.toContain(`SET "status" = 'COMPLETED'`);
|
||||
});
|
||||
|
||||
it('rolls back archive writes when completing the import run fails', async () => {
|
||||
const target = targetPool(`SET "status" = 'COMPLETED'`);
|
||||
await expect(migrateGame(sourcePool(), target.pool, true, 'che')).rejects.toThrow(
|
||||
'synthetic archive write failure'
|
||||
);
|
||||
|
||||
expect(target.queries.some((entry) => entry.sql === 'COMMIT')).toBe(false);
|
||||
expect(target.queries.some((entry) => entry.sql === 'ROLLBACK')).toBe(true);
|
||||
expect(target.queries.some((entry) => entry.sql.includes(`SET "status" = 'FAILED'`))).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects an unsupported profile before reading or writing', async () => {
|
||||
await expect(migrateGame(sourcePool(), null, false, 'custom')).rejects.toThrow(
|
||||
'Unsupported legacy archive profile'
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user