fix(migration): 검증 실패 아이콘을 명시적으로 제외한다
검토한 회원 번호의 이미지가 계속 유효성 조건을 어길 때만 업로드에서 제외한다. 기존 Ref 경로가 현재 선택인 경우 기본 아이콘으로 정리하고 최신 Core 선택은 보존한다.
This commit is contained in:
@@ -86,6 +86,14 @@ is not silently resurrected. The original Ref path, `IMGSVR`, returned path and
|
|||||||
byte SHA-256 remain in `legacy_data`. Picture collisions across owners fail the
|
byte SHA-256 remain in `legacy_data`. Picture collisions across owners fail the
|
||||||
transaction.
|
transaction.
|
||||||
|
|
||||||
|
Historical bytes that violate the Ref validation contract block preflight.
|
||||||
|
Operators may list a reviewed member in `excludedMemberNumbers`; the importer
|
||||||
|
then proves the file is still invalid and records the reason. It never uploads
|
||||||
|
that byte or creates a `user_icon` row. If the target still selects the rejected
|
||||||
|
Ref path it moves only that selection to `default.jpg`; a newer Core selection
|
||||||
|
is preserved. A stale exclusion whose file has become valid also blocks the
|
||||||
|
plan, so this cannot become a general skip-errors switch.
|
||||||
|
|
||||||
Kakao members retain `oauth_id`, email and metadata. A non-empty provider ID is
|
Kakao members retain `oauth_id`, email and metadata. A non-empty provider ID is
|
||||||
required before an imported row is marked Kakao-verified. A parseable legacy
|
required before an imported row is marked Kakao-verified. A parseable legacy
|
||||||
`token_valid_until` is copied to `kakao_talk_verified_until`, preserving the
|
`token_valid_until` is copied to `kakao_talk_verified_until`, preserving the
|
||||||
|
|||||||
@@ -44,6 +44,12 @@ is mandatory. Mount Ref's `d_pic` directory read-only as `sourceDirectory`, and
|
|||||||
mount the Core2026 sam-image upload secret as a mode-0600 `uploadSecretFile`.
|
mount the Core2026 sam-image upload secret as a mode-0600 `uploadSecretFile`.
|
||||||
The two URL fields normally point to `https://sam-image.hided.net` and its
|
The two URL fields normally point to `https://sam-image.hided.net` and its
|
||||||
`/icons` path. The importer never adds account images to the image Git tree.
|
`/icons` path. The importer never adds account images to the image Git tree.
|
||||||
|
An invalid historical file blocks the plan by default. After byte-level review,
|
||||||
|
its member number may be listed in `excludedMemberNumbers`; the exclusion is
|
||||||
|
accepted only while that exact member still has invalid image geometry/format.
|
||||||
|
A valid file or stale/missing member exclusion fails closed. An unchanged
|
||||||
|
invalid Ref selection is reset to the default icon instead of publishing bad
|
||||||
|
bytes; a newer Core selection is preserved.
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
mkdir -p tools/legacy-db-migration/secrets
|
mkdir -p tools/legacy-db-migration/secrets
|
||||||
|
|||||||
@@ -14,7 +14,8 @@
|
|||||||
"sourceDirectory": "/run/sammo-migration/user-icons",
|
"sourceDirectory": "/run/sammo-migration/user-icons",
|
||||||
"uploadBaseUrl": "https://sam-image.hided.net",
|
"uploadBaseUrl": "https://sam-image.hided.net",
|
||||||
"publicBaseUrl": "https://sam-image.hided.net/icons",
|
"publicBaseUrl": "https://sam-image.hided.net/icons",
|
||||||
"uploadSecretFile": "/run/secrets/image_upload_core2026_secret"
|
"uploadSecretFile": "/run/secrets/image_upload_core2026_secret",
|
||||||
|
"excludedMemberNumbers": []
|
||||||
},
|
},
|
||||||
"targetUrlEnv": "GATEWAY_DATABASE_URL"
|
"targetUrlEnv": "GATEWAY_DATABASE_URL"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -169,7 +169,11 @@ const resolveUserIcons = async (
|
|||||||
label: string
|
label: string
|
||||||
): Promise<LegacyUserIconTransferConfig> => {
|
): Promise<LegacyUserIconTransferConfig> => {
|
||||||
const record = asRecord(value, label);
|
const record = asRecord(value, label);
|
||||||
rejectUnknownKeys(record, ['sourceDirectory', 'uploadBaseUrl', 'publicBaseUrl', 'uploadSecretFile'], label);
|
rejectUnknownKeys(
|
||||||
|
record,
|
||||||
|
['sourceDirectory', 'uploadBaseUrl', 'publicBaseUrl', 'uploadSecretFile', 'excludedMemberNumbers'],
|
||||||
|
label
|
||||||
|
);
|
||||||
const sourceDirectory = path.resolve(configDirectory, requiredString(record, 'sourceDirectory', label));
|
const sourceDirectory = path.resolve(configDirectory, requiredString(record, 'sourceDirectory', label));
|
||||||
const sourceInfo = await lstat(sourceDirectory);
|
const sourceInfo = await lstat(sourceDirectory);
|
||||||
if (!sourceInfo.isDirectory() || sourceInfo.isSymbolicLink()) {
|
if (!sourceInfo.isDirectory() || sourceInfo.isSymbolicLink()) {
|
||||||
@@ -180,7 +184,20 @@ const resolveUserIcons = async (
|
|||||||
const secretPath = path.resolve(configDirectory, requiredString(record, 'uploadSecretFile', label));
|
const secretPath = path.resolve(configDirectory, requiredString(record, 'uploadSecretFile', label));
|
||||||
const uploadSecret = (await readSecureText(secretPath, `${label}.uploadSecretFile`)).replace(/\r?\n$/u, '');
|
const uploadSecret = (await readSecureText(secretPath, `${label}.uploadSecretFile`)).replace(/\r?\n$/u, '');
|
||||||
if (uploadSecret.length < 32) throw new Error(`${label}.uploadSecretFile must contain at least 32 characters`);
|
if (uploadSecret.length < 32) throw new Error(`${label}.uploadSecretFile must contain at least 32 characters`);
|
||||||
return { sourceDirectory, uploadBaseUrl, publicBaseUrl, uploadSecret };
|
const excludedMemberNumbers = record.excludedMemberNumbers ?? [];
|
||||||
|
if (
|
||||||
|
!Array.isArray(excludedMemberNumbers) ||
|
||||||
|
excludedMemberNumbers.some((value) => !Number.isSafeInteger(value) || Number(value) <= 0)
|
||||||
|
) {
|
||||||
|
throw new Error(`${label}.excludedMemberNumbers must contain only positive safe integers`);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
sourceDirectory,
|
||||||
|
uploadBaseUrl,
|
||||||
|
publicBaseUrl,
|
||||||
|
uploadSecret,
|
||||||
|
excludedMemberNumbers: excludedMemberNumbers as number[],
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export const loadMigrationPlan = async (configPathInput: string): Promise<ResolvedMigrationPlan> => {
|
export const loadMigrationPlan = async (configPathInput: string): Promise<ResolvedMigrationPlan> => {
|
||||||
|
|||||||
@@ -31,9 +31,11 @@ import {
|
|||||||
normalizeLegacyIconPicture,
|
normalizeLegacyIconPicture,
|
||||||
prepareLegacyUserIcons,
|
prepareLegacyUserIcons,
|
||||||
syncImportedUserIcons,
|
syncImportedUserIcons,
|
||||||
|
syncRejectedUserIcons,
|
||||||
type LegacyUserIconPreparation,
|
type LegacyUserIconPreparation,
|
||||||
type LegacyUserIconTransferConfig,
|
type LegacyUserIconTransferConfig,
|
||||||
type PreparedLegacyUserIcon,
|
type PreparedLegacyUserIcon,
|
||||||
|
type RejectedLegacyUserIcon,
|
||||||
} from './legacyUserIcons.js';
|
} from './legacyUserIcons.js';
|
||||||
import { mapLegacyRoles, mapLegacySanctions, parseJson, type JsonValue } from './transform.js';
|
import { mapLegacyRoles, mapLegacySanctions, parseJson, type JsonValue } from './transform.js';
|
||||||
|
|
||||||
@@ -119,7 +121,8 @@ export const mapMember = (
|
|||||||
row: SourceRow,
|
row: SourceRow,
|
||||||
migratedAt: Date,
|
migratedAt: Date,
|
||||||
lastLoginAt: Date | null,
|
lastLoginAt: Date | null,
|
||||||
importedIcon?: PreparedLegacyUserIcon
|
importedIcon?: PreparedLegacyUserIcon,
|
||||||
|
rejectedIcon?: RejectedLegacyUserIcon
|
||||||
): TargetRow => {
|
): TargetRow => {
|
||||||
const memberNo = toNumber(row.NO, 'member.NO');
|
const memberNo = toNumber(row.NO, 'member.NO');
|
||||||
const grade = toNumber(row.GRADE, `member.${memberNo}.GRADE`);
|
const grade = toNumber(row.GRADE, `member.${memberNo}.GRADE`);
|
||||||
@@ -139,6 +142,7 @@ export const mapMember = (
|
|||||||
picture: rawPicture,
|
picture: rawPicture,
|
||||||
imageServer,
|
imageServer,
|
||||||
...(importedIcon ? { importedPicture: importedIcon.picture, importedPictureSha256: importedIcon.sha256 } : {}),
|
...(importedIcon ? { importedPicture: importedIcon.picture, importedPictureSha256: importedIcon.sha256 } : {}),
|
||||||
|
...(rejectedIcon ? { rejectedPictureReason: rejectedIcon.reason } : {}),
|
||||||
tokenValidUntil: toNullableString(row.token_valid_until),
|
tokenValidUntil: toNullableString(row.token_valid_until),
|
||||||
regNum: toNumber(row.REG_NUM, `member.${memberNo}.REG_NUM`),
|
regNum: toNumber(row.REG_NUM, `member.${memberNo}.REG_NUM`),
|
||||||
blockNum: toNumber(row.BLOCK_NUM, `member.${memberNo}.BLOCK_NUM`),
|
blockNum: toNumber(row.BLOCK_NUM, `member.${memberNo}.BLOCK_NUM`),
|
||||||
@@ -157,8 +161,8 @@ export const mapMember = (
|
|||||||
oauth_id: oauthId,
|
oauth_id: oauthId,
|
||||||
email: toNullableString(row.EMAIL)?.toLowerCase() ?? null,
|
email: toNullableString(row.EMAIL)?.toLowerCase() ?? null,
|
||||||
oauth_info: jsonParameter(oauthInfo),
|
oauth_info: jsonParameter(oauthInfo),
|
||||||
picture: importedIcon?.picture ?? normalizeLegacyIconPicture(rawPicture),
|
picture: importedIcon?.picture ?? (rejectedIcon ? 'default.jpg' : normalizeLegacyIconPicture(rawPicture)),
|
||||||
image_server: importedIcon?.imageServer ?? imageServer,
|
image_server: importedIcon?.imageServer ?? (rejectedIcon ? 0 : imageServer),
|
||||||
icon_updated_at: null,
|
icon_updated_at: null,
|
||||||
third_party_use: toNumber(row.third_use ?? 0, `member.${memberNo}.third_use`) !== 0,
|
third_party_use: toNumber(row.third_use ?? 0, `member.${memberNo}.third_use`) !== 0,
|
||||||
terms_accepted_at: null,
|
terms_accepted_at: null,
|
||||||
@@ -196,21 +200,24 @@ const processMembers = async (
|
|||||||
apply: boolean,
|
apply: boolean,
|
||||||
migratedAt: Date,
|
migratedAt: Date,
|
||||||
counts: Record<string, number>,
|
counts: Record<string, number>,
|
||||||
preparedIcons: ReadonlyMap<number, PreparedLegacyUserIcon>
|
preparedIcons: ReadonlyMap<number, PreparedLegacyUserIcon>,
|
||||||
|
rejectedIcons: ReadonlyMap<number, RejectedLegacyUserIcon>
|
||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
const lastLogins = await loadLastLogins(source);
|
const lastLogins = await loadLastLogins(source);
|
||||||
for await (const rows of paginateSource(source, 'member', 'NO', batchSize)) {
|
for await (const rows of paginateSource(source, 'member', 'NO', batchSize)) {
|
||||||
const mapped = rows.map((row) => {
|
const mapped = rows.map((row) => {
|
||||||
const memberNo = toNumber(row.NO, 'member.NO');
|
const memberNo = toNumber(row.NO, 'member.NO');
|
||||||
const importedIcon = preparedIcons.get(memberNo);
|
const importedIcon = preparedIcons.get(memberNo);
|
||||||
|
const rejectedIcon = rejectedIcons.get(memberNo);
|
||||||
const sourcePicture = toNullableString(row.PICTURE) ?? 'default.jpg';
|
const sourcePicture = toNullableString(row.PICTURE) ?? 'default.jpg';
|
||||||
if (
|
if (
|
||||||
(sourcePicture !== 'default.jpg' && !importedIcon) ||
|
(sourcePicture !== 'default.jpg' && !importedIcon && !rejectedIcon) ||
|
||||||
(importedIcon && importedIcon.sourcePicture !== sourcePicture)
|
(importedIcon && importedIcon.sourcePicture !== sourcePicture) ||
|
||||||
|
(rejectedIcon && rejectedIcon.sourcePicture !== sourcePicture)
|
||||||
) {
|
) {
|
||||||
throw new Error(`member.${memberNo}.PICTURE changed after user-icon preflight`);
|
throw new Error(`member.${memberNo}.PICTURE changed after user-icon preflight`);
|
||||||
}
|
}
|
||||||
return mapMember(row, migratedAt, lastLogins.get(memberNo) ?? null, importedIcon);
|
return mapMember(row, migratedAt, lastLogins.get(memberNo) ?? null, importedIcon, rejectedIcon);
|
||||||
});
|
});
|
||||||
if (target) {
|
if (target) {
|
||||||
await preflightMemberConflicts(target, mapped);
|
await preflightMemberConflicts(target, mapped);
|
||||||
@@ -228,6 +235,17 @@ const processMembers = async (
|
|||||||
counts.user_icon_library_inserted = (counts.user_icon_library_inserted ?? 0) + synced.libraryInserted;
|
counts.user_icon_library_inserted = (counts.user_icon_library_inserted ?? 0) + synced.libraryInserted;
|
||||||
counts.user_icon_library_retired = (counts.user_icon_library_retired ?? 0) + synced.libraryRetired;
|
counts.user_icon_library_retired = (counts.user_icon_library_retired ?? 0) + synced.libraryRetired;
|
||||||
counts.user_icon_target_preserved = (counts.user_icon_target_preserved ?? 0) + synced.targetPreserved;
|
counts.user_icon_target_preserved = (counts.user_icon_target_preserved ?? 0) + synced.targetPreserved;
|
||||||
|
const rejected = await syncRejectedUserIcons(
|
||||||
|
target,
|
||||||
|
rows
|
||||||
|
.map((row) => rejectedIcons.get(toNumber(row.NO, 'member.NO')))
|
||||||
|
.filter((icon): icon is RejectedLegacyUserIcon => Boolean(icon)),
|
||||||
|
migratedAt
|
||||||
|
);
|
||||||
|
counts.user_icon_rejected_current_reset =
|
||||||
|
(counts.user_icon_rejected_current_reset ?? 0) + rejected.currentReset;
|
||||||
|
counts.user_icon_rejected_target_preserved =
|
||||||
|
(counts.user_icon_rejected_target_preserved ?? 0) + rejected.targetPreserved;
|
||||||
}
|
}
|
||||||
counts.member = (counts.member ?? 0) + mapped.length;
|
counts.member = (counts.member ?? 0) + mapped.length;
|
||||||
}
|
}
|
||||||
@@ -351,10 +369,11 @@ export const migrateGateway = async (
|
|||||||
counts.user_icon_legacy_file = prepared.counts.legacyFiles;
|
counts.user_icon_legacy_file = prepared.counts.legacyFiles;
|
||||||
counts.user_icon_existing_upload = prepared.counts.existingUploads;
|
counts.user_icon_existing_upload = prepared.counts.existingUploads;
|
||||||
counts.user_icon_uploaded = prepared.counts.uploaded;
|
counts.user_icon_uploaded = prepared.counts.uploaded;
|
||||||
|
counts.user_icon_rejected = prepared.counts.rejected;
|
||||||
};
|
};
|
||||||
const run = async (runId: string | null, prepared: LegacyUserIconPreparation): Promise<void> => {
|
const run = async (runId: string | null, prepared: LegacyUserIconPreparation): Promise<void> => {
|
||||||
recordIconCounts(prepared);
|
recordIconCounts(prepared);
|
||||||
await processMembers(source, client, apply, migratedAt, counts, prepared.icons);
|
await processMembers(source, client, apply, migratedAt, counts, prepared.icons, prepared.rejected);
|
||||||
progress.member = {
|
progress.member = {
|
||||||
strategy: 'rescan',
|
strategy: 'rescan',
|
||||||
startAfterId: null,
|
startAfterId: null,
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ export interface LegacyUserIconTransferConfig {
|
|||||||
uploadBaseUrl: string;
|
uploadBaseUrl: string;
|
||||||
publicBaseUrl: string;
|
publicBaseUrl: string;
|
||||||
uploadSecret: string;
|
uploadSecret: string;
|
||||||
|
excludedMemberNumbers: readonly number[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PreparedLegacyUserIcon {
|
export interface PreparedLegacyUserIcon {
|
||||||
@@ -44,14 +45,25 @@ export interface PreparedLegacyUserIcon {
|
|||||||
|
|
||||||
export interface LegacyUserIconPreparation {
|
export interface LegacyUserIconPreparation {
|
||||||
icons: Map<number, PreparedLegacyUserIcon>;
|
icons: Map<number, PreparedLegacyUserIcon>;
|
||||||
|
rejected: Map<number, RejectedLegacyUserIcon>;
|
||||||
counts: {
|
counts: {
|
||||||
custom: number;
|
custom: number;
|
||||||
legacyFiles: number;
|
legacyFiles: number;
|
||||||
existingUploads: number;
|
existingUploads: number;
|
||||||
uploaded: number;
|
uploaded: number;
|
||||||
|
rejected: number;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface RejectedLegacyUserIcon {
|
||||||
|
memberNo: number;
|
||||||
|
userId: string;
|
||||||
|
sourcePicture: string;
|
||||||
|
normalizedSourcePicture: string;
|
||||||
|
sourceImageServer: number;
|
||||||
|
reason: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface LegacyUserIconSyncCounts {
|
export interface LegacyUserIconSyncCounts {
|
||||||
currentLinked: number;
|
currentLinked: number;
|
||||||
libraryInserted: number;
|
libraryInserted: number;
|
||||||
@@ -59,6 +71,11 @@ export interface LegacyUserIconSyncCounts {
|
|||||||
targetPreserved: number;
|
targetPreserved: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface RejectedLegacyUserIconSyncCounts {
|
||||||
|
currentReset: number;
|
||||||
|
targetPreserved: number;
|
||||||
|
}
|
||||||
|
|
||||||
interface ValidatedImage {
|
interface ValidatedImage {
|
||||||
body: Buffer;
|
body: Buffer;
|
||||||
extension: string;
|
extension: string;
|
||||||
@@ -66,6 +83,8 @@ interface ValidatedImage {
|
|||||||
sha256: string;
|
sha256: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class LegacyUserIconValidationError extends Error {}
|
||||||
|
|
||||||
const encodedPicturePath = (picture: string): string => picture.split('/').map(encodeURIComponent).join('/');
|
const encodedPicturePath = (picture: string): string => picture.split('/').map(encodeURIComponent).join('/');
|
||||||
|
|
||||||
export const normalizeLegacyIconPicture = (picture: string): string => picture.replace(LEGACY_CACHE_SUFFIX, '');
|
export const normalizeLegacyIconPicture = (picture: string): string => picture.replace(LEGACY_CACHE_SUFFIX, '');
|
||||||
@@ -82,21 +101,29 @@ const iconCreatedAt = (sourcePicture: string, fallback: Date): Date => {
|
|||||||
|
|
||||||
const validateImage = async (body: Buffer, label: string): Promise<ValidatedImage> => {
|
const validateImage = async (body: Buffer, label: string): Promise<ValidatedImage> => {
|
||||||
if (body.length === 0 || body.length > MAX_ICON_BYTES) {
|
if (body.length === 0 || body.length > MAX_ICON_BYTES) {
|
||||||
throw new Error(`${label} must be non-empty and at most 50 KiB`);
|
throw new LegacyUserIconValidationError(`${label} must be non-empty and at most 50 KiB`);
|
||||||
}
|
}
|
||||||
let metadata: { mediaType?: string; format?: string; width?: number; height?: number };
|
let metadata: {
|
||||||
|
mediaType?: string;
|
||||||
|
format?: string;
|
||||||
|
width?: number;
|
||||||
|
height?: number;
|
||||||
|
pageHeight?: number;
|
||||||
|
pages?: number;
|
||||||
|
};
|
||||||
try {
|
try {
|
||||||
metadata = await sharp(body, { animated: true }).metadata();
|
metadata = await sharp(body, { animated: true }).metadata();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new Error(`${label} is not a decodable image`, { cause: error });
|
throw new LegacyUserIconValidationError(`${label} is not a decodable image`, { cause: error });
|
||||||
}
|
}
|
||||||
const detected = metadata.mediaType === 'image/avif' ? 'avif' : metadata.format;
|
const detected = metadata.mediaType === 'image/avif' ? 'avif' : metadata.format;
|
||||||
const extension = detected === 'jpeg' ? 'jpg' : detected;
|
const extension = detected === 'jpeg' ? 'jpg' : detected;
|
||||||
if (!extension || !CONTENT_TYPES[extension]) {
|
if (!extension || !CONTENT_TYPES[extension]) {
|
||||||
throw new Error(`${label} must be avif, webp, jpeg, png, or gif`);
|
throw new LegacyUserIconValidationError(`${label} must be avif, webp, jpeg, png, or gif`);
|
||||||
}
|
}
|
||||||
if (!metadata.width || metadata.width < 64 || metadata.width > 128 || metadata.height !== metadata.width) {
|
const frameHeight = metadata.pages && metadata.pages > 1 ? metadata.pageHeight : metadata.height;
|
||||||
throw new Error(`${label} must be a square image from 64x64 through 128x128`);
|
if (!metadata.width || metadata.width < 64 || metadata.width > 128 || frameHeight !== metadata.width) {
|
||||||
|
throw new LegacyUserIconValidationError(`${label} must be a square image from 64x64 through 128x128`);
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
body,
|
body,
|
||||||
@@ -119,7 +146,7 @@ const readLegacyIcon = async (directory: string, picture: string, memberNo: numb
|
|||||||
throw new Error(`member.${memberNo}.PICTURE must resolve to a regular non-symlink file`);
|
throw new Error(`member.${memberNo}.PICTURE must resolve to a regular non-symlink file`);
|
||||||
}
|
}
|
||||||
if (info.size === 0 || info.size > MAX_ICON_BYTES) {
|
if (info.size === 0 || info.size > MAX_ICON_BYTES) {
|
||||||
throw new Error(`member.${memberNo}.PICTURE must be non-empty and at most 50 KiB`);
|
throw new LegacyUserIconValidationError(`member.${memberNo}.PICTURE must be non-empty and at most 50 KiB`);
|
||||||
}
|
}
|
||||||
const handle = await open(filePath, constants.O_RDONLY | constants.O_NOFOLLOW);
|
const handle = await open(filePath, constants.O_RDONLY | constants.O_NOFOLLOW);
|
||||||
try {
|
try {
|
||||||
@@ -248,22 +275,59 @@ export const prepareLegacyUserIcons = async (
|
|||||||
throw new Error('Gateway source has custom icons but gateway.userIcons is not configured');
|
throw new Error('Gateway source has custom icons but gateway.userIcons is not configured');
|
||||||
}
|
}
|
||||||
if (!config) {
|
if (!config) {
|
||||||
return { icons: new Map(), counts: { custom: 0, legacyFiles: 0, existingUploads: 0, uploaded: 0 } };
|
return {
|
||||||
|
icons: new Map(),
|
||||||
|
rejected: new Map(),
|
||||||
|
counts: { custom: 0, legacyFiles: 0, existingUploads: 0, uploaded: 0, rejected: 0 },
|
||||||
|
};
|
||||||
}
|
}
|
||||||
if (config.uploadSecret.length < 32) {
|
if (config.uploadSecret.length < 32) {
|
||||||
throw new Error('gateway.userIcons.uploadSecretFile must contain at least 32 characters');
|
throw new Error('gateway.userIcons.uploadSecretFile must contain at least 32 characters');
|
||||||
}
|
}
|
||||||
const fetchImpl = options.fetchImpl ?? fetch;
|
const fetchImpl = options.fetchImpl ?? fetch;
|
||||||
const now = options.now ?? Date.now;
|
const now = options.now ?? Date.now;
|
||||||
|
const exclusions = new Set(config.excludedMemberNumbers);
|
||||||
|
if (exclusions.size !== config.excludedMemberNumbers.length) {
|
||||||
|
throw new Error('gateway.userIcons.excludedMemberNumbers must not contain duplicates');
|
||||||
|
}
|
||||||
const validated = await mapWithConcurrency(customRows, options.concurrency ?? 8, async (row) => {
|
const validated = await mapWithConcurrency(customRows, options.concurrency ?? 8, async (row) => {
|
||||||
const memberNo = toNumber(row.NO, 'member.NO');
|
const memberNo = toNumber(row.NO, 'member.NO');
|
||||||
const sourcePicture = toStringValue(row.PICTURE, `member.${memberNo}.PICTURE`);
|
const sourcePicture = toStringValue(row.PICTURE, `member.${memberNo}.PICTURE`);
|
||||||
const normalizedSourcePicture = normalizeLegacyIconPicture(sourcePicture);
|
const normalizedSourcePicture = normalizeLegacyIconPicture(sourcePicture);
|
||||||
const sourceImageServer = toNumber(row.IMGSVR ?? 0, `member.${memberNo}.IMGSVR`);
|
const sourceImageServer = toNumber(row.IMGSVR ?? 0, `member.${memberNo}.IMGSVR`);
|
||||||
const fallbackCreatedAt = toDate(row.REG_DATE, `member.${memberNo}.REG_DATE`);
|
const fallbackCreatedAt = toDate(row.REG_DATE, `member.${memberNo}.REG_DATE`);
|
||||||
if (REMOTE_PICTURE.test(normalizedSourcePicture)) {
|
try {
|
||||||
const image = await fetchExistingIcon(config, normalizedSourcePicture, memberNo, fetchImpl);
|
if (REMOTE_PICTURE.test(normalizedSourcePicture)) {
|
||||||
|
const image = await fetchExistingIcon(config, normalizedSourcePicture, memberNo, fetchImpl);
|
||||||
|
if (exclusions.has(memberNo)) {
|
||||||
|
throw new Error(`member.${memberNo} is configured as excluded but its icon is valid`);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
kind: 'icon' as const,
|
||||||
|
base: {
|
||||||
|
memberNo,
|
||||||
|
userId: legacyUserId(memberNo),
|
||||||
|
sourcePicture,
|
||||||
|
normalizedSourcePicture,
|
||||||
|
sourceImageServer,
|
||||||
|
imageServer: 0 as const,
|
||||||
|
createdAt: iconCreatedAt(sourcePicture, fallbackCreatedAt),
|
||||||
|
source: 'existing-upload' as const,
|
||||||
|
sha256: image.sha256,
|
||||||
|
},
|
||||||
|
image,
|
||||||
|
picture: normalizedSourcePicture,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (sourceImageServer !== 1) {
|
||||||
|
throw new Error(`member.${memberNo}.PICTURE has an unsupported IMGSVR value`);
|
||||||
|
}
|
||||||
|
const image = await readLegacyIcon(config.sourceDirectory, normalizedSourcePicture, memberNo);
|
||||||
|
if (exclusions.has(memberNo)) {
|
||||||
|
throw new Error(`member.${memberNo} is configured as excluded but its icon is valid`);
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
|
kind: 'icon' as const,
|
||||||
base: {
|
base: {
|
||||||
memberNo,
|
memberNo,
|
||||||
userId: legacyUserId(memberNo),
|
userId: legacyUserId(memberNo),
|
||||||
@@ -272,42 +336,44 @@ export const prepareLegacyUserIcons = async (
|
|||||||
sourceImageServer,
|
sourceImageServer,
|
||||||
imageServer: 0 as const,
|
imageServer: 0 as const,
|
||||||
createdAt: iconCreatedAt(sourcePicture, fallbackCreatedAt),
|
createdAt: iconCreatedAt(sourcePicture, fallbackCreatedAt),
|
||||||
source: 'existing-upload' as const,
|
source: 'legacy-file' as const,
|
||||||
sha256: image.sha256,
|
sha256: image.sha256,
|
||||||
},
|
},
|
||||||
image,
|
image,
|
||||||
picture: normalizedSourcePicture,
|
picture: `users/core2026/${deterministicUploadName(memberNo, normalizedSourcePicture, image)}`,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
if (!(error instanceof LegacyUserIconValidationError) || !exclusions.has(memberNo)) throw error;
|
||||||
|
return {
|
||||||
|
kind: 'rejected' as const,
|
||||||
|
rejected: {
|
||||||
|
memberNo,
|
||||||
|
userId: legacyUserId(memberNo),
|
||||||
|
sourcePicture,
|
||||||
|
normalizedSourcePicture,
|
||||||
|
sourceImageServer,
|
||||||
|
reason: error.message,
|
||||||
|
} satisfies RejectedLegacyUserIcon,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (sourceImageServer !== 1) {
|
|
||||||
throw new Error(`member.${memberNo}.PICTURE has an unsupported IMGSVR value`);
|
|
||||||
}
|
|
||||||
const image = await readLegacyIcon(config.sourceDirectory, normalizedSourcePicture, memberNo);
|
|
||||||
return {
|
|
||||||
base: {
|
|
||||||
memberNo,
|
|
||||||
userId: legacyUserId(memberNo),
|
|
||||||
sourcePicture,
|
|
||||||
normalizedSourcePicture,
|
|
||||||
sourceImageServer,
|
|
||||||
imageServer: 0 as const,
|
|
||||||
createdAt: iconCreatedAt(sourcePicture, fallbackCreatedAt),
|
|
||||||
source: 'legacy-file' as const,
|
|
||||||
sha256: image.sha256,
|
|
||||||
},
|
|
||||||
image,
|
|
||||||
picture: `users/core2026/${deterministicUploadName(memberNo, normalizedSourcePicture, image)}`,
|
|
||||||
};
|
|
||||||
});
|
});
|
||||||
|
const missingExclusions = [...exclusions].filter(
|
||||||
|
(memberNo) => !validated.some((result) => result.kind === 'rejected' && result.rejected.memberNo === memberNo)
|
||||||
|
);
|
||||||
|
if (missingExclusions.length) {
|
||||||
|
throw new Error(`Configured user-icon exclusions were not rejected: ${missingExclusions.join(', ')}`);
|
||||||
|
}
|
||||||
|
const validIcons = validated.filter((result) => result.kind === 'icon');
|
||||||
|
const rejectedIcons = validated.filter((result) => result.kind === 'rejected').map((result) => result.rejected);
|
||||||
const pictures = new Map<string, number>();
|
const pictures = new Map<string, number>();
|
||||||
for (const icon of validated) {
|
for (const icon of validIcons) {
|
||||||
const owner = pictures.get(icon.picture);
|
const owner = pictures.get(icon.picture);
|
||||||
if (owner !== undefined && owner !== icon.base.memberNo) {
|
if (owner !== undefined && owner !== icon.base.memberNo) {
|
||||||
throw new Error('Legacy user icon picture is shared by multiple source accounts');
|
throw new Error('Legacy user icon picture is shared by multiple source accounts');
|
||||||
}
|
}
|
||||||
pictures.set(icon.picture, icon.base.memberNo);
|
pictures.set(icon.picture, icon.base.memberNo);
|
||||||
}
|
}
|
||||||
const prepared = await mapWithConcurrency(validated, options.concurrency ?? 8, async (icon) => {
|
const prepared = await mapWithConcurrency(validIcons, options.concurrency ?? 8, async (icon) => {
|
||||||
const picture =
|
const picture =
|
||||||
apply && icon.base.source === 'legacy-file'
|
apply && icon.base.source === 'legacy-file'
|
||||||
? await uploadLegacyIcon(
|
? await uploadLegacyIcon(
|
||||||
@@ -323,15 +389,56 @@ export const prepareLegacyUserIcons = async (
|
|||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
icons: new Map(prepared.map((icon) => [icon.memberNo, icon])),
|
icons: new Map(prepared.map((icon) => [icon.memberNo, icon])),
|
||||||
|
rejected: new Map(rejectedIcons.map((icon) => [icon.memberNo, icon])),
|
||||||
counts: {
|
counts: {
|
||||||
custom: prepared.length,
|
custom: validated.length,
|
||||||
legacyFiles: prepared.filter((icon) => icon.source === 'legacy-file').length,
|
legacyFiles: prepared.filter((icon) => icon.source === 'legacy-file').length,
|
||||||
existingUploads: prepared.filter((icon) => icon.source === 'existing-upload').length,
|
existingUploads: prepared.filter((icon) => icon.source === 'existing-upload').length,
|
||||||
uploaded: apply ? prepared.filter((icon) => icon.source === 'legacy-file').length : 0,
|
uploaded: apply ? prepared.filter((icon) => icon.source === 'legacy-file').length : 0,
|
||||||
|
rejected: rejectedIcons.length,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const syncRejectedUserIcons = async (
|
||||||
|
target: PoolClient,
|
||||||
|
rejected: readonly RejectedLegacyUserIcon[],
|
||||||
|
migratedAt: Date
|
||||||
|
): Promise<RejectedLegacyUserIconSyncCounts> => {
|
||||||
|
if (rejected.length === 0) return { currentReset: 0, targetPreserved: 0 };
|
||||||
|
const accounts = await target.query<{ id: string; picture: string; image_server: number }>(
|
||||||
|
`SELECT "id", "picture", "image_server" FROM "app_user" WHERE "id" = ANY($1::text[]) FOR UPDATE`,
|
||||||
|
[rejected.map((icon) => icon.userId)]
|
||||||
|
);
|
||||||
|
const byId = new Map(accounts.rows.map((account) => [account.id, account]));
|
||||||
|
const counts = { currentReset: 0, targetPreserved: 0 };
|
||||||
|
for (const icon of rejected) {
|
||||||
|
const account = byId.get(icon.userId);
|
||||||
|
if (!account) throw new Error(`Imported member account is missing for member.${icon.memberNo}`);
|
||||||
|
const sourceMatchesCurrent =
|
||||||
|
account.picture === icon.sourcePicture || account.picture === icon.normalizedSourcePicture;
|
||||||
|
if (sourceMatchesCurrent) {
|
||||||
|
const reset = await target.query(
|
||||||
|
`UPDATE "app_user"
|
||||||
|
SET "picture" = 'default.jpg', "image_server" = 0,
|
||||||
|
"icon_revision" = GREATEST(
|
||||||
|
COALESCE("icon_revision", "icon_updated_at", "created_at"),
|
||||||
|
$2::timestamptz
|
||||||
|
)
|
||||||
|
WHERE "id" = $1 AND "picture" = $3 AND "image_server" = $4`,
|
||||||
|
[icon.userId, migratedAt, account.picture, account.image_server]
|
||||||
|
);
|
||||||
|
if (reset.rowCount !== 1) {
|
||||||
|
throw new Error(`Target account icon changed concurrently for member.${icon.memberNo}`);
|
||||||
|
}
|
||||||
|
counts.currentReset += 1;
|
||||||
|
} else {
|
||||||
|
counts.targetPreserved += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return counts;
|
||||||
|
};
|
||||||
|
|
||||||
export const syncImportedUserIcons = async (
|
export const syncImportedUserIcons = async (
|
||||||
target: PoolClient,
|
target: PoolClient,
|
||||||
icons: readonly PreparedLegacyUserIcon[],
|
icons: readonly PreparedLegacyUserIcon[],
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ export interface PlanRunSummary {
|
|||||||
interface StagePreflight {
|
interface StagePreflight {
|
||||||
battleResults?: { seasons: number; files: number; bytes: number };
|
battleResults?: { seasons: number; files: number; bytes: number };
|
||||||
battleResultManifests?: readonly BattleResultSeasonManifest[];
|
battleResultManifests?: readonly BattleResultSeasonManifest[];
|
||||||
userIcons?: { custom: number; legacyFiles: number; existingUploads: number };
|
userIcons?: { custom: number; legacyFiles: number; existingUploads: number; rejected: number };
|
||||||
}
|
}
|
||||||
|
|
||||||
const preflightStage = async (stage: ResolvedMigrationStage): Promise<StagePreflight> => {
|
const preflightStage = async (stage: ResolvedMigrationStage): Promise<StagePreflight> => {
|
||||||
@@ -90,6 +90,7 @@ const preflightStage = async (stage: ResolvedMigrationStage): Promise<StagePrefl
|
|||||||
custom: prepared.counts.custom,
|
custom: prepared.counts.custom,
|
||||||
legacyFiles: prepared.counts.legacyFiles,
|
legacyFiles: prepared.counts.legacyFiles,
|
||||||
existingUploads: prepared.counts.existingUploads,
|
existingUploads: prepared.counts.existingUploads,
|
||||||
|
rejected: prepared.counts.rejected,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { Pool } from 'pg';
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
import { legacyUserId } from '../src/identity.js';
|
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;
|
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).toHaveLength(3);
|
||||||
expect(library.rows.filter((row) => row.retired_at !== null)).toHaveLength(1);
|
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 {
|
} finally {
|
||||||
await client.query('ROLLBACK');
|
await client.query('ROLLBACK');
|
||||||
client.release();
|
client.release();
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { legacyUserId } from '../src/identity.js';
|
|||||||
import {
|
import {
|
||||||
prepareLegacyUserIcons,
|
prepareLegacyUserIcons,
|
||||||
syncImportedUserIcons,
|
syncImportedUserIcons,
|
||||||
|
syncRejectedUserIcons,
|
||||||
type LegacyUserIconTransferConfig,
|
type LegacyUserIconTransferConfig,
|
||||||
type PreparedLegacyUserIcon,
|
type PreparedLegacyUserIcon,
|
||||||
} from '../src/legacyUserIcons.js';
|
} from '../src/legacyUserIcons.js';
|
||||||
@@ -36,6 +37,7 @@ const createFixture = async (): Promise<{ config: LegacyUserIconTransferConfig;
|
|||||||
uploadBaseUrl: 'https://upload.test',
|
uploadBaseUrl: 'https://upload.test',
|
||||||
publicBaseUrl: 'https://public.test/icons',
|
publicBaseUrl: 'https://public.test/icons',
|
||||||
uploadSecret: 's'.repeat(32),
|
uploadSecret: 's'.repeat(32),
|
||||||
|
excludedMemberNumbers: [],
|
||||||
},
|
},
|
||||||
png,
|
png,
|
||||||
};
|
};
|
||||||
@@ -57,7 +59,13 @@ describe('legacy user icon transfer', () => {
|
|||||||
const first = await prepareLegacyUserIcons([sourceRow()], config, false, { fetchImpl });
|
const first = await prepareLegacyUserIcons([sourceRow()], config, false, { fetchImpl });
|
||||||
const second = 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(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(second.icons.get(7)?.picture).toBe(first.icons.get(7)?.picture);
|
||||||
expect(fetchImpl).not.toHaveBeenCalled();
|
expect(fetchImpl).not.toHaveBeenCalled();
|
||||||
@@ -123,7 +131,13 @@ describe('legacy user icon transfer', () => {
|
|||||||
{ fetchImpl }
|
{ 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(result.icons.get(7)?.picture).toBe(picture);
|
||||||
expect(fetchImpl).toHaveBeenCalledTimes(1);
|
expect(fetchImpl).toHaveBeenCalledTimes(1);
|
||||||
expect(String(fetchImpl.mock.calls[0]?.[0])).toBe(`https://public.test/icons/${picture}`);
|
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 () => {
|
it('links an unchanged Ref selection while preserving newer Core selections', async () => {
|
||||||
const imported = (memberNo: number, sourcePicture: string, picture: string): PreparedLegacyUserIcon => ({
|
const imported = (memberNo: number, sourcePicture: string, picture: string): PreparedLegacyUserIcon => ({
|
||||||
memberNo,
|
memberNo,
|
||||||
@@ -179,4 +227,30 @@ describe('legacy user icon transfer', () => {
|
|||||||
expect(inserts).toHaveLength(3);
|
expect(inserts).toHaveLength(3);
|
||||||
expect(inserts[2]?.[1]?.[3]).toEqual(new Date('2026-08-24T00:00:00Z'));
|
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'`);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user