Files
photo-live/services/api/test/db.semantics.e2e-spec.ts
T
浅念 59464bd73d feat(api): photo, upload, outbox schema
Task 10: photos/photo_variants/upload_sessions/photo_jobs/outbox_events
on the CN schema (flat string FKs, no Prisma relations); migration
20260917120827_photo_upload_domain rewritten to utf8mb4_0900_bin per the
固化 collation recipe; reset.ts table order extended; one semantics e2e
case for photo_jobs @@unique([photoId, type]) and the upload_sessions
A03 idempotency anchor @@unique([userId, clientTaskId]).

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-17 20:11:26 +08:00

222 lines
7.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/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' });
});
});