2026-09-17 19:51:56 +08:00
|
|
|
|
// 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)。
|
2026-09-17 19:51:56 +08:00
|
|
|
|
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' });
|
|
|
|
|
|
});
|
2026-09-17 19:51:56 +08:00
|
|
|
|
});
|