Files
photo-live/services/api/test/reset.ts
T
浅念 a2a3ccd340 feat(api): idempotent upload session init with presigned credential
POST /v1/uploads (Task 18, A03): @@unique(userId, clientTaskId) idempotency
anchor — same params return the original session (201, no duplicate photo);
param drift (album/bytes/contentType/extension) -> 409 UPLOAD_SESSION_CONFLICT.
photos.objectKey is set to the FINAL originals key at init while the session
holds the staging key; IDs come from an app-side cuid-like generator so both
self-referencing keys are written in one transaction. Credential is a real
MinIO presigned PUT (SIGNED_URL_TTL_SECONDS, lazily read). Permission order:
album invisible 404 -> member 403 -> uploadEnabled 409 -> size/ext 400 ->
category ownership 400. No audit on init (audit anchors on complete).

Pre-step for Task 19: QUEUE_PHOTO_PROCESS contract constant + lazy-safe
BullMQ queue producer (Redis down at boot must not crash the app; enqueue
failure only logs — sweep compensates). e2e reset now also wipes the MinIO
photo-live/dev prefix; jest maps @photo/storage to TS source.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-17 22:42:13 +08:00

113 lines
3.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// services/api/test/reset.ts
// e2e 共用隔离工具:每个 e2e spec 的 beforeAll 调用 resetDb(),防止 spec 间
// 唯一约束冲突与脏数据。
// 实现:SET FOREIGN_KEY_CHECKS=0 包裹逐表 TRUNCATE(users 被 sessions/audit_logs
// 引用,即便子表已清空,存在 FK 约束本身即令 TRUNCATE 失败,必须关检查);
// 表序仍按 FK 安全顺序排列(子表在前),关检查失败时也不至于中途卡死。
// 顺序 await 在 Prisma 连接池上复用同一空闲连接,会话级 FOREIGN_KEY_CHECKS
// 对后续 TRUNCATE 生效;finally 恢复检查并断开,失败也不泄漏连接。
// Task 18 起 e2e 会向 MinIO 写入真实对象(staging/originals):同一函数内追加
// `photo-live/dev` 前缀对象清理(reset.ts 文件头 TODO 的落实——只清本前缀,
// packages/storage 的 test/<ts>-<uuid>/ 前缀对象不受影响,两包测试可并行)。
import {
DeleteObjectsCommand,
ListObjectsV2Command,
S3Client,
} from '@aws-sdk/client-s3';
import { PrismaClient } from '@photo/db-cn';
export const RESET_TABLES: readonly string[] = [
// 照片与上传域 + outbox(Task 10):均为扁平字符串外键(无 FK 约束),
// 顺序按逻辑依赖排列(事件/作业/变体/会话先于 photos)。
'outbox_events',
'photo_jobs',
'photo_variants',
'upload_sessions',
'photos',
// 相册域(Task 9):子表(翻译/资产/成员)在前,albums 在后;相册域为扁平
// 字符串外键(无 FK 约束),此处顺序仅为逻辑可读,不依赖 FK 检查开关。
'category_translations',
'categories',
'album_assets',
'album_translations',
'album_members',
'albums',
// 身份域(Task 8)。
'audit_logs',
'sessions',
'users',
];
/** e2e 对象隔离边界:与 src/uploads/support.ts 的 OBJECT_KEY_ROOT 一致。 */
const RESET_OBJECT_PREFIX = 'photo-live/dev/';
/** 删除 MinIO 中 RESET_OBJECT_PREFIX 前缀的全部对象(分页列举 + 批量删除)。 */
async function resetMinioObjects(): Promise<void> {
const required = [
'S3_ENDPOINT',
'S3_REGION',
'S3_ACCESS_KEY',
'S3_SECRET_KEY',
'S3_BUCKET',
] as const;
const missing = required.filter((key) => !process.env[key]);
if (missing.length > 0) {
throw new Error(`resetMinioObjects: 缺少环境变量 ${missing.join(', ')}`);
}
const client = new S3Client({
endpoint: process.env.S3_ENDPOINT,
region: process.env.S3_REGION,
credentials: {
accessKeyId: process.env.S3_ACCESS_KEY as string,
secretAccessKey: process.env.S3_SECRET_KEY as string,
},
forcePathStyle: true,
});
const bucket = process.env.S3_BUCKET as string;
try {
for (;;) {
const listed = await client.send(
new ListObjectsV2Command({
Bucket: bucket,
Prefix: RESET_OBJECT_PREFIX,
MaxKeys: 1000,
}),
);
const objects = (listed.Contents ?? []).filter(
(obj) => typeof obj.Key === 'string',
);
if (objects.length === 0) {
break;
}
await client.send(
new DeleteObjectsCommand({
Bucket: bucket,
Delete: {
Objects: objects.map((obj) => ({ Key: obj.Key as string })),
Quiet: true,
},
}),
);
if (!listed.IsTruncated) {
break;
}
}
} finally {
await client.destroy();
}
}
export async function resetDb(): Promise<void> {
await resetMinioObjects();
const prisma = new PrismaClient();
try {
await prisma.$executeRawUnsafe('SET FOREIGN_KEY_CHECKS = 0');
for (const table of RESET_TABLES) {
await prisma.$executeRawUnsafe(`TRUNCATE TABLE ${table}`);
}
} finally {
await prisma.$executeRawUnsafe('SET FOREIGN_KEY_CHECKS = 1');
await prisma.$disconnect();
}
}