fix(migration): 검증 실패 아이콘을 명시적으로 제외한다

검토한 회원 번호의 이미지가 계속 유효성 조건을 어길 때만 업로드에서 제외한다. 기존 Ref 경로가 현재 선택인 경우 기본 아이콘으로 정리하고 최신 Core 선택은 보존한다.
This commit is contained in:
2026-08-24 08:45:47 +00:00
parent c88a8de9bc
commit cd0f50f89d
9 changed files with 313 additions and 49 deletions
@@ -2,7 +2,7 @@ import { Pool } from 'pg';
import { describe, expect, it } from 'vitest';
import { legacyUserId } from '../src/identity.js';
import { syncImportedUserIcons, type PreparedLegacyUserIcon } from '../src/legacyUserIcons.js';
import { syncImportedUserIcons, syncRejectedUserIcons, type PreparedLegacyUserIcon } from '../src/legacyUserIcons.js';
const databaseUrl = process.env.LEGACY_ICON_TEST_DATABASE_URL;
@@ -69,6 +69,37 @@ describe.skipIf(!databaseUrl)('legacy user icon PostgreSQL synchronization', ()
);
expect(library.rows).toHaveLength(3);
expect(library.rows.filter((row) => row.retired_at !== null)).toHaveLength(1);
const rejectedUserId = legacyUserId(700_004);
await client.query(
`INSERT INTO "app_user"
("id", "login_id", "display_name", "password_hash", "password_salt",
"updated_at", "picture", "image_server")
VALUES ($1, 'icon-test-rejected', '아이콘테스트-제외', 'hash', 'salt',
CURRENT_TIMESTAMP, 'invalid.gif?=20260809', 1)`,
[rejectedUserId]
);
await expect(
syncRejectedUserIcons(
client,
[
{
memberNo: 700_004,
userId: rejectedUserId,
sourcePicture: 'invalid.gif?=20260809',
normalizedSourcePicture: 'invalid.gif',
sourceImageServer: 1,
reason: 'not square',
},
],
new Date('2026-08-24T00:00:00Z')
)
).resolves.toEqual({ currentReset: 1, targetPreserved: 0 });
const rejectedAccount = await client.query<{ picture: string; image_server: number }>(
`SELECT "picture", "image_server" FROM "app_user" WHERE "id" = $1`,
[rejectedUserId]
);
expect(rejectedAccount.rows[0]).toEqual({ picture: 'default.jpg', image_server: 0 });
} finally {
await client.query('ROLLBACK');
client.release();
@@ -11,6 +11,7 @@ import { legacyUserId } from '../src/identity.js';
import {
prepareLegacyUserIcons,
syncImportedUserIcons,
syncRejectedUserIcons,
type LegacyUserIconTransferConfig,
type PreparedLegacyUserIcon,
} from '../src/legacyUserIcons.js';
@@ -36,6 +37,7 @@ const createFixture = async (): Promise<{ config: LegacyUserIconTransferConfig;
uploadBaseUrl: 'https://upload.test',
publicBaseUrl: 'https://public.test/icons',
uploadSecret: 's'.repeat(32),
excludedMemberNumbers: [],
},
png,
};
@@ -57,7 +59,13 @@ describe('legacy user icon transfer', () => {
const first = await prepareLegacyUserIcons([sourceRow()], config, false, { fetchImpl });
const second = await prepareLegacyUserIcons([sourceRow()], config, false, { fetchImpl });
expect(first.counts).toEqual({ custom: 1, legacyFiles: 1, existingUploads: 0, uploaded: 0 });
expect(first.counts).toEqual({
custom: 1,
legacyFiles: 1,
existingUploads: 0,
uploaded: 0,
rejected: 0,
});
expect(first.icons.get(7)?.picture).toMatch(/^users\/core2026\/[a-f0-9]{32}\.png$/u);
expect(second.icons.get(7)?.picture).toBe(first.icons.get(7)?.picture);
expect(fetchImpl).not.toHaveBeenCalled();
@@ -123,7 +131,13 @@ describe('legacy user icon transfer', () => {
{ fetchImpl }
);
expect(result.counts).toEqual({ custom: 1, legacyFiles: 0, existingUploads: 1, uploaded: 0 });
expect(result.counts).toEqual({
custom: 1,
legacyFiles: 0,
existingUploads: 1,
uploaded: 0,
rejected: 0,
});
expect(result.icons.get(7)?.picture).toBe(picture);
expect(fetchImpl).toHaveBeenCalledTimes(1);
expect(String(fetchImpl.mock.calls[0]?.[0])).toBe(`https://public.test/icons/${picture}`);
@@ -135,6 +149,40 @@ describe('legacy user icon transfer', () => {
);
});
it('permits only an explicitly reviewed member exclusion whose bytes remain invalid', async () => {
const { config } = await createFixture();
const invalidGif = await sharp({
create: { width: 64, height: 65, channels: 4, background: '#336699ff' },
})
.gif()
.toBuffer();
await writeFile(path.join(config.sourceDirectory, 'invalid.gif'), invalidGif);
config.excludedMemberNumbers = [7];
const result = await prepareLegacyUserIcons([sourceRow({ PICTURE: 'invalid.gif?=20260809' })], config, true, {
fetchImpl: vi.fn<typeof fetch>(),
});
expect(result.counts).toEqual({
custom: 1,
legacyFiles: 0,
existingUploads: 0,
uploaded: 0,
rejected: 1,
});
expect(result.rejected.get(7)?.reason).toContain('square image');
expect(result.icons.size).toBe(0);
});
it('rejects a stale exclusion when the configured member icon is valid', async () => {
const { config } = await createFixture();
config.excludedMemberNumbers = [7];
await expect(prepareLegacyUserIcons([sourceRow()], config, false)).rejects.toThrow(
'configured as excluded but its icon is valid'
);
});
it('links an unchanged Ref selection while preserving newer Core selections', async () => {
const imported = (memberNo: number, sourcePicture: string, picture: string): PreparedLegacyUserIcon => ({
memberNo,
@@ -179,4 +227,30 @@ describe('legacy user icon transfer', () => {
expect(inserts).toHaveLength(3);
expect(inserts[2]?.[1]?.[3]).toEqual(new Date('2026-08-24T00:00:00Z'));
});
it('resets only the still-selected invalid Ref icon', async () => {
const userId = legacyUserId(7);
const rejected = {
memberNo: 7,
userId,
sourcePicture: 'invalid.gif?=20260809',
normalizedSourcePicture: 'invalid.gif',
sourceImageServer: 1,
reason: 'not square',
};
const query = vi.fn(async (sql: string, _parameters?: readonly unknown[]) => {
if (sql.includes('FROM "app_user"')) {
return {
rows: [{ id: userId, picture: rejected.sourcePicture, image_server: 1 }],
rowCount: 1,
} as QueryResult;
}
return { rows: [], rowCount: 1 } as unknown as QueryResult;
});
await expect(
syncRejectedUserIcons({ query } as unknown as PoolClient, [rejected], new Date('2026-08-24T00:00:00Z'))
).resolves.toEqual({ currentReset: 1, targetPreserved: 0 });
expect(String(query.mock.calls[1]?.[0])).toContain(`SET "picture" = 'default.jpg'`);
});
});