59464bd73d
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>
222 lines
7.8 KiB
TypeScript
222 lines
7.8 KiB
TypeScript
// 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' });
|
||
});
|
||
});
|