Files
core2026/tools/legacy-db-migration/test/gateway.test.ts
T

168 lines
6.3 KiB
TypeScript

import type { Pool as MariaPool } from 'mariadb';
import type { Pool as PgPool, PoolClient, QueryResult } from 'pg';
import { describe, expect, it, vi } from 'vitest';
import { upsertRows } from '../src/db.js';
import { mapMember, MEMBER_PRESERVED_COLUMNS, migrateGateway, preflightMemberConflicts } from '../src/gateway.js';
const memberRow = (overrides: Record<string, unknown> = {}) => ({
NO: 7,
GRADE: 1,
acl: '{}',
penalty: '{}',
oauth_info: '{}',
oauth_type: 'KAKAO',
oauth_id: null,
PW: 'a'.repeat(128),
salt: 'member-salt',
ID: 'LegacyUser',
NAME: '레거시유저',
EMAIL: 'USER@EXAMPLE.TEST',
PICTURE: 'default.jpg',
IMGSVR: 0,
third_use: 0,
token_valid_until: null,
delete_after: null,
REG_DATE: '2020-01-01 00:00:00',
REG_NUM: 0,
BLOCK_NUM: 0,
BLOCK_DATE: null,
...overrides,
});
describe('legacy gateway member migration', () => {
it('marks imported SHA-512 credentials for reset without trusting a missing Kakao ID', () => {
const mapped = mapMember(memberRow(), new Date('2026-08-17T00:00:00.000Z'), null);
expect(mapped).toMatchObject({
login_id: 'legacyuser',
email: 'user@example.test',
password_reset_required: true,
oauth_type: 'KAKAO',
oauth_id: null,
kakao_verified_at: null,
});
});
it('preserves target-owned credentials and OAuth state on a repeated member upsert', async () => {
const query = vi.fn(async (_sql: string, _values?: unknown[]) => ({ rows: [], rowCount: 0 }));
const client = { query } as unknown as PoolClient;
await upsertRows(
client,
'app_user',
[
{
id: 'legacy-id',
login_id: 'legacy-user',
password_hash: 'legacy-hash',
oauth_info: '{}',
legacy_data: '{}',
},
],
['id'],
{ preserveOnConflict: ['password_hash', 'oauth_info'] }
);
const sql = String(query.mock.calls[0]?.[0]);
expect(sql).toContain('"login_id" = EXCLUDED."login_id"');
expect(sql).toContain('"legacy_data" = EXCLUDED."legacy_data"');
expect(sql).not.toContain('"password_hash" = EXCLUDED."password_hash"');
expect(sql).not.toContain('"oauth_info" = EXCLUDED."oauth_info"');
});
it('preserves renamed login and display identities along with every live credential field', () => {
expect(MEMBER_PRESERVED_COLUMNS).toEqual(
expect.arrayContaining([
'login_id',
'display_name',
'password_hash',
'password_salt',
'password_reset_required',
'roles',
'sanctions',
'oauth_id',
'email',
'updated_at',
'last_login_at',
'created_at',
])
);
expect(MEMBER_PRESERVED_COLUMNS).not.toContain('legacy_data');
});
it('rejects a source member when another target account already owns its identity', async () => {
const query = vi.fn(async (..._args: unknown[]) => ({
rows: [
{
id: 'another-target-id',
login_id: 'legacyuser',
display_name: '다른사용자',
email: 'other@example.test',
},
],
rowCount: 1,
}));
const client = { query } as unknown as PoolClient;
const mapped = mapMember(memberRow({ oauth_id: 'stable-kakao-id' }), new Date('2026-08-17T00:00:00Z'), null);
await expect(preflightMemberConflicts(client, [mapped])).rejects.toThrow(
'Target account identity collision in legacy member batch'
);
expect(String(query.mock.calls[0]?.[0])).toContain('FROM "app_user"');
});
it('reads only new immutable member logs during an incremental dry-run', async () => {
const fingerprint = 'a'.repeat(64);
const seen = new Set<string>();
const source = {
query: vi.fn(async (sql: string, values: readonly unknown[] = []) => {
if (sql.includes('MAX(`id`)') && sql.includes('member_log')) return [{ max_id: 2 }];
if (sql.includes('MAX(`date`)')) return [];
if (sql.includes('FROM `member`')) return [];
if (sql.includes('FROM `member_log`')) {
if (seen.has('member_log')) return [];
seen.add('member_log');
return Number(values[0]) < 2
? [
{
id: 2,
member_no: 7,
date: new Date('2026-08-18T00:00:00.000Z'),
action_type: 'login',
action: null,
},
]
: [];
}
return [];
}),
} as unknown as MariaPool;
const query = vi.fn(async (sql: string) => {
if (sql.includes('FROM "legacy_import_checkpoint"')) {
return {
rows: [{ source_fingerprint: fingerprint, last_legacy_id: '1' }],
rowCount: 1,
} as QueryResult;
}
return { rows: [], rowCount: 0 } as unknown as QueryResult;
});
const client = { query, release: vi.fn() } as unknown as PoolClient;
const target = { connect: vi.fn(async () => client) } as unknown as PgPool;
const summary = await migrateGateway(source, target, false, new Date('2026-08-18T00:00:00.000Z'), {
mode: 'incremental',
source: { key: 'fixture:gateway', fingerprint },
});
expect(summary.counts.member_log).toBe(1);
expect(summary.progress?.member_log).toMatchObject({
strategy: 'append',
startAfterId: '1',
endAtId: '2',
processed: 1,
});
expect(query.mock.calls.some(([sql]) => String(sql).includes('INSERT INTO "legacy_member_log"'))).toBe(false);
});
});