// services/api/test/db.semantics.e2e-spec.ts —— Task 8 语义复核(真实 MySQL 8.4, // 库级 utf8mb4_0900_bin;连接经 test/load-env.ts 载入根 .env 的 DATABASE_URL_CN)。 // 复核项是后续 auth/album 语义的依赖前提: // 1) DateTime(6) 的 UTC 瞬间往返(写入+读回毫秒精度不丢、时区不改); // 2) bin collation 下账号查找大小写敏感(token/account 语义同此); // 3) audit_logs 的 Json 元数据落库与 actor 关系; // 4) 必选关系默认 Restrict:删除仍持有 session 的用户抛 P2003——保留可审计性, // 刻意不做 onDelete: Cascade(用户决策,见计划 Task 8)。 // 5) 相册域(Task 9 增补):多语言翻译按 @@unique([albumId, locale]) 双向 // 精确命中、publicToken 唯一约束拒绝重复(P2002)——相册域为扁平字符串 // 外键(无 Prisma relation),完整 CRUD 语义由 Task 15 展开。 // 6) 照片与上传域(Task 10 增补):photo_jobs 按 @@unique([photoId, type]) // 拒绝重复作业、upload_sessions 按 @@unique([userId, clientTaskId]) 拒绝 // 重复会话(P2002)——后者是断点续传幂等锚点(A03)。 import { PrismaClient } from '@photo/db-cn'; import { resetDb } from './reset'; describe('db semantics (e2e)', () => { let prisma: PrismaClient; beforeAll(async () => { await resetDb(); prisma = new PrismaClient(); }); afterAll(async () => { await prisma.$disconnect(); }); it('DateTime(6) round-trips a UTC instant at millisecond precision', async () => { const createdAt = new Date('2026-09-17T08:00:00.123Z'); const created = await prisma.users.create({ data: { account: 'utc-roundtrip', passwordHash: 'x', displayName: 'UTC 往返', createdAt, }, select: { createdAt: true }, }); expect(created.createdAt.getTime()).toBe(createdAt.getTime()); const reread = await prisma.users.findUnique({ where: { account: 'utc-roundtrip' }, select: { createdAt: true }, }); expect(reread?.createdAt.getTime()).toBe(createdAt.getTime()); expect(reread?.createdAt.toISOString()).toBe('2026-09-17T08:00:00.123Z'); }); it('account lookup is case-sensitive under utf8mb4_0900_bin', async () => { await prisma.users.create({ data: { account: 'AdminUser', passwordHash: 'x', displayName: '大小写敏感', }, }); const lowered = await prisma.users.findUnique({ where: { account: 'adminuser' }, }); expect(lowered).toBeNull(); const exact = await prisma.users.findUnique({ where: { account: 'AdminUser' }, }); expect(exact?.account).toBe('AdminUser'); }); it('stores audit log Json metadata and joins back to the actor', async () => { const actor = await prisma.users.create({ data: { account: 'audit-actor', passwordHash: 'x', displayName: '审计者', }, }); const log = await prisma.audit_logs.create({ data: { actorId: actor.id, action: 'user.login', resourceId: 'res-001', metadata: { ip: '127.0.0.1', userAgent: 'e2e', attempts: 1 }, }, include: { actor: true }, }); expect(log.id).toBeTruthy(); expect(log.metadata).toEqual({ ip: '127.0.0.1', userAgent: 'e2e', attempts: 1 }); expect(log.actor?.id).toBe(actor.id); expect(log.actor?.account).toBe('audit-actor'); // 反向关系:经 actor.auditLogs 能取回审计记录。 const viaActor = await prisma.users.findUnique({ where: { id: actor.id }, include: { auditLogs: true }, }); expect(viaActor?.auditLogs.map((l) => l.id)).toContain(log.id); }); it('rejects deleting a user that still has a session (FK Restrict, no cascade)', async () => { const user = await prisma.users.create({ data: { account: 'restricted', passwordHash: 'x', displayName: '受保护删除', }, }); await prisma.sessions.create({ data: { userId: user.id, tokenHash: 'a'.repeat(64), expiresAt: new Date(Date.now() + 60_000), }, }); // Prisma 对必选关系默认 onDelete: Restrict —— 删除被引用的 users 抛 P2003 // (外键约束失败)。这正是预期:会话/审计可追溯,不允许级联抹除。 await expect( prisma.users.delete({ where: { id: user.id } }), ).rejects.toMatchObject({ code: 'P2003' }); }); it('album translations resolve by (albumId, locale) both ways and publicToken is unique (Task 9)', async () => { const album = await prisma.albums.create({ data: { publicToken: 'album-tok-e2e-01', ownerId: 'owner-flat-fk', // 相册域扁平字符串外键,无 FK 约束 startAt: new Date('2026-10-01T00:00:00Z'), defaultLocale: 'zh-CN', }, }); await prisma.album_translations.create({ data: { albumId: album.id, locale: 'zh-CN', title: '婚礼相册' }, }); await prisma.album_translations.create({ data: { albumId: album.id, locale: 'en', title: 'Wedding Album' }, }); // 复合唯一键两个方向都能精确命中(bin 校对:locale 大小写敏感)。 const zh = await prisma.album_translations.findUnique({ where: { albumId_locale: { albumId: album.id, locale: 'zh-CN' } }, }); expect(zh?.title).toBe('婚礼相册'); const en = await prisma.album_translations.findUnique({ where: { albumId_locale: { albumId: album.id, locale: 'en' } }, }); expect(en?.title).toBe('Wedding Album'); // 同一 (albumId, locale) 重复写入被唯一约束拒绝(P2002)。 await expect( prisma.album_translations.create({ data: { albumId: album.id, locale: 'en', title: 'Dup' }, }), ).rejects.toMatchObject({ code: 'P2002' }); // publicToken 全局唯一:第二个相册复用同 token 抛 P2002。 await expect( prisma.albums.create({ data: { publicToken: 'album-tok-e2e-01', ownerId: 'owner-flat-fk', startAt: new Date('2026-11-01T00:00:00Z'), defaultLocale: 'zh-CN', }, }), ).rejects.toMatchObject({ code: 'P2002' }); }); it('photo_jobs and upload_sessions enforce their unique anchors (Task 10)', async () => { const photo = await prisma.photos.create({ data: { albumId: 'album-jobs-e2e', uploaderId: 'uploader-flat-fk', objectKey: 'originals/album-jobs-e2e/photo-001.jpg', bytes: 1024, }, }); await prisma.photo_jobs.create({ data: { photoId: photo.id, type: 'process' }, }); // 同一照片同类作业唯一(worker 任务领取的防重入锚点)。 await expect( prisma.photo_jobs.create({ data: { photoId: photo.id, type: 'process' }, }), ).rejects.toMatchObject({ code: 'P2002' }); await prisma.upload_sessions.create({ data: { userId: 'uploader-flat-fk', albumId: 'album-jobs-e2e', clientTaskId: 'task-0001', photoId: photo.id, objectKey: 'staging/album-jobs-e2e/task-0001.jpg', expectedBytes: 1024, contentType: 'image/jpeg', expiresAt: new Date(Date.now() + 60_000), }, }); // 同一 (userId, clientTaskId) 唯一——断点续传幂等锚点(A03)。 // photoId/objectKey 换新值,确保 P2002 只可能来自复合唯一键本身。 await expect( prisma.upload_sessions.create({ data: { userId: 'uploader-flat-fk', albumId: 'album-jobs-e2e', clientTaskId: 'task-0001', photoId: 'other-photo-cuid', objectKey: 'staging/album-jobs-e2e/task-0002.jpg', expectedBytes: 2048, contentType: 'image/jpeg', expiresAt: new Date(Date.now() + 60_000), }, }), ).rejects.toMatchObject({ code: 'P2002' }); }); });