168 lines
6.3 KiB
TypeScript
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);
|
|
});
|
|
});
|