Files
photo-live/services/api/test/db.semantics.e2e-spec.ts
T
浅念 2801e4f158 feat(api): identity and audit schema with mysql semantics tests
packages/db-cn:@photo/db-cn(prisma 5.22,api/worker 共享 client)+
schema(users/sessions/audit_logs,DateTime(6)、cuid、账号唯一)。
迁移 SQL 手工改写表级 collation 为 utf8mb4_0900_bin——Prisma 生成的 MySQL
DDL 固定 utf8mb4_unicode_ci 会覆盖库级 bin collation,账号/token 大小写
敏感语义依赖它(后续迁移需同样复核)。services/api:@Global PrismaModule
(PrismaService 封装 $connect/$disconnect,池上限仍由
DATABASE_URL_CN ?connection_limit=10 承担);reset.ts 落地 FK 包裹式
TRUNCATE;e2e 语义复核 4 例(UTC 往返/大小写敏感/audit Json+actor/
session FK Restrict P2003)。pnpm 10 未拦截 prisma engines(平台包内置),
无需 onlyBuiltDependencies。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-17 19:51:56 +08:00

119 lines
3.9 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)。
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' });
});
});