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