Files
photo-live/services/api/test/db.semantics.e2e-spec.ts
T

222 lines
7.8 KiB
TypeScript
Raw Normal View History

// 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)。
2026-09-17 20:05:09 +08:00
// 5) 相册域(Task 9 增补):多语言翻译按 @@unique([albumId, locale]) 双向
// 精确命中、publicToken 唯一约束拒绝重复(P2002)——相册域为扁平字符串
// 外键(无 Prisma relation),完整 CRUD 语义由 Task 15 展开。
2026-09-17 20:11:26 +08:00
// 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' });
});
2026-09-17 20:05:09 +08:00
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' });
});
2026-09-17 20:11:26 +08:00
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' });
});
});