Files
plp/tests/integration/database.spec.ts
T
2026-09-16 09:55:03 +08:00

528 lines
16 KiB
TypeScript

import { randomUUID } from "node:crypto";
import { PrismaClient } from "@prisma/client";
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
import { PrismaService } from "../../apps/api/src/database/prisma.service";
import {
assertSafeTestDatabaseUrl,
resolveTestDatabaseUrl,
} from "../../prisma/database-safety";
const databaseUrl = resolveTestDatabaseUrl();
const prisma = new PrismaClient({ datasources: { db: { url: databaseUrl } } });
async function expectConstraintViolation(operation: Promise<unknown>) {
await expect(operation).rejects.toMatchObject({
name: "PrismaClientUnknownRequestError",
});
}
async function expectUniqueViolation(operation: Promise<unknown>) {
await expect(operation).rejects.toMatchObject({ code: "P2002" });
}
async function createAccount(suffix: string) {
return prisma.account.create({
data: {
phoneCiphertext: Buffer.from(`ciphertext-${suffix}`),
phoneHmac: `hmac-${suffix}`,
anonymousProfile: {
create: {
publicId: randomUUID(),
nickname: `漂友-${suffix}`,
avatarColor: "#66CCFF",
reviewStatus: "APPROVED",
},
},
},
});
}
async function createBottle(authorId: string, suffix: string) {
return prisma.bottle.create({
data: {
authorId,
contentText: `测试瓶子-${suffix}`,
reviewStatus: "APPROVED",
poolStatus: "IN_POOL",
version: 1,
approvedAt: new Date(),
},
});
}
describe("database authority constraints", () => {
beforeAll(async () => {
assertSafeTestDatabaseUrl(databaseUrl);
await prisma.$connect();
});
beforeEach(async () => {
assertSafeTestDatabaseUrl(databaseUrl);
await prisma.$executeRawUnsafe(`TRUNCATE TABLE "accounts" CASCADE`);
});
afterAll(async () => prisma.$disconnect());
it("defines indexed refresh-token history", async () => {
const columns = await prisma.$queryRaw<Array<{ column_name: string }>>`
SELECT column_name FROM information_schema.columns
WHERE table_schema = 'public' AND table_name = 'refresh_tokens'`;
expect(columns.map(({ column_name }) => column_name)).toEqual(
expect.arrayContaining([
"token_hash",
"session_id",
"generation",
"status",
"used_at",
"replaced_by_hash",
"created_at",
"expires_at",
]),
);
const indexes = await prisma.$queryRaw<Array<{ indexdef: string }>>`
SELECT indexdef FROM pg_indexes WHERE schemaname = 'public' AND tablename = 'refresh_tokens'`;
expect(
indexes.some(
({ indexdef }) =>
indexdef.includes("UNIQUE") && indexdef.includes("token_hash"),
),
).toBe(true);
expect(
indexes.some(
({ indexdef }) =>
indexdef.includes("session_id") && indexdef.includes("generation"),
),
).toBe(true);
});
it("uses the database-only bottle pick candidate partial index", async () => {
const author = await createAccount("candidate-index-author");
await prisma.bottle.createMany({
data: [
...Array.from({ length: 2_000 }, (_, index) => ({
authorId: author.id,
clientRequestId: `ineligible-${index}`,
contentText: `ineligible-${index}`,
reviewStatus: "REVIEWING" as const,
poolStatus: "CLOSED" as const,
})),
...Array.from({ length: 3 }, (_, index) => ({
authorId: author.id,
clientRequestId: `eligible-${index}`,
contentText: `eligible-${index}`,
reviewStatus: "APPROVED" as const,
poolStatus: "IN_POOL" as const,
approvedAt: new Date(),
})),
],
});
const [index] = await prisma.$queryRaw<Array<{ indexdef: string }>>`
SELECT indexdef FROM pg_indexes
WHERE schemaname = 'public' AND tablename = 'bottles'
AND indexname = 'bottles_pick_candidates_id_idx'`;
expect(index?.indexdef).toMatch(
/ON public\.bottles USING btree \(id\) WHERE .*review_status = 'APPROVED'.*pool_status = 'IN_POOL'.*active_lease_id IS NULL/,
);
const { plan, rows } = await prisma.$transaction(async (tx) => {
await tx.$executeRawUnsafe("SET LOCAL enable_seqscan = off");
const explained = await tx.$queryRawUnsafe<
Array<{ "QUERY PLAN": Array<{ Plan: { "Index Name"?: string } }> }>
>(`EXPLAIN (FORMAT JSON)
SELECT id FROM bottles
WHERE review_status = 'APPROVED'
AND pool_status = 'IN_POOL'
AND active_lease_id IS NULL
ORDER BY id LIMIT 32`);
const selected = await tx.$queryRaw<Array<{ content_text: string }>>`
SELECT content_text FROM bottles
WHERE review_status = 'APPROVED'
AND pool_status = 'IN_POOL'
AND active_lease_id IS NULL
ORDER BY id LIMIT 32`;
return { plan: explained[0]!["QUERY PLAN"][0]!.Plan, rows: selected };
});
const indexNames = (node: unknown): string[] => {
if (!node || typeof node !== "object") return [];
const record = node as Record<string, unknown>;
return [
...(typeof record["Index Name"] === "string"
? [record["Index Name"]]
: []),
...Object.values(record).flatMap(indexNames),
];
};
expect(indexNames(plan)).toContain("bottles_pick_candidates_id_idx");
expect(rows.map(({ content_text }) => content_text).sort()).toEqual([
"eligible-0",
"eligible-1",
"eligible-2",
]);
});
it("backfills and requires the session token version snapshot", async () => {
const columns = await prisma.$queryRaw<
Array<{ is_nullable: string; column_default: string | null }>
>`
SELECT is_nullable, column_default FROM information_schema.columns
WHERE table_schema = 'public' AND table_name = 'sessions' AND column_name = 'token_version'`;
expect(columns).toHaveLength(1);
expect(columns[0]).toMatchObject({
is_nullable: "NO",
column_default: "0",
});
});
it("expresses independent moderation and pool lifecycle state", async () => {
const author = await createAccount("states");
const bottle = await prisma.bottle.create({
data: {
authorId: author.id,
contentText: "state",
reviewStatus: "MANUAL_REVIEW",
poolStatus: "LEASED",
version: 3,
approvedAt: new Date(),
consumedAt: new Date(),
},
include: { author: { include: { anonymousProfile: true } } },
});
expect(bottle).toMatchObject({
reviewStatus: "MANUAL_REVIEW",
poolStatus: "LEASED",
version: 3,
});
expect(bottle.author.anonymousProfile).toMatchObject({
avatarColor: "#66CCFF",
reviewStatus: "APPROVED",
});
});
it("enforces unique anonymous public ids", async () => {
const first = await createAccount("public-id");
const publicId = (
await prisma.anonymousProfile.findUniqueOrThrow({
where: { accountId: first.id },
})
).publicId;
await expectUniqueViolation(
prisma.account.create({
data: {
phoneCiphertext: Buffer.from("other"),
phoneHmac: "other-public-id",
anonymousProfile: {
create: {
publicId,
nickname: "另一位",
avatarColor: "#000000",
},
},
},
}),
);
});
it("allows only one ACTIVE lease per bottle and allows ended leases", async () => {
const author = await createAccount("lease-author");
const firstPicker = await createAccount("lease-picker-a");
const secondPicker = await createAccount("lease-picker-b");
const bottle = await createBottle(author.id, "lease");
const first = await prisma.bottlePickLease.create({
data: {
bottleId: bottle.id,
pickerId: firstPicker.id,
leaseTokenHash: "lease-a",
leaseTokenCiphertext: Buffer.from("ciphertext-a"),
expiresAt: new Date(Date.now() + 60_000),
},
});
await expectUniqueViolation(
prisma.bottlePickLease.create({
data: {
bottleId: bottle.id,
pickerId: secondPicker.id,
leaseTokenHash: "lease-b",
leaseTokenCiphertext: Buffer.from("ciphertext-b"),
expiresAt: new Date(Date.now() + 60_000),
},
}),
);
await prisma.bottlePickLease.update({
where: { id: first.id },
data: { status: "RETURNED", endedAt: new Date() },
});
await expect(
prisma.bottlePickLease.create({
data: {
bottleId: bottle.id,
pickerId: secondPicker.id,
leaseTokenHash: "lease-c",
leaseTokenCiphertext: Buffer.from("ciphertext-c"),
expiresAt: new Date(Date.now() + 60_000),
},
}),
).resolves.toMatchObject({ status: "ACTIVE" });
});
it("supports bottle-targeted moderation tasks without a report", async () => {
const author = await createAccount("moderation");
const bottle = await createBottle(author.id, "moderation");
await expect(
prisma.moderationTask.create({
data: {
targetType: "BOTTLE",
targetId: bottle.id,
provider: "internal",
result: { verdict: "review" },
riskLabels: ["safety"],
payloadHash: "payload-hash",
status: "PENDING",
},
}),
).resolves.toMatchObject({ targetType: "BOTTLE", reportId: null });
});
it("stores conversation/message snapshots and bigint sequences", async () => {
const author = await createAccount("message");
const bottle = await createBottle(author.id, "message");
const conversation = await prisma.conversation.create({
data: { sourceBottleId: bottle.id, nextSeq: 2n },
});
const member = await prisma.conversationMember.create({
data: {
conversationId: conversation.id,
accountId: author.id,
peerAliasSnapshot: "匿名海风",
},
});
const message = await prisma.message.create({
data: {
conversationId: conversation.id,
senderId: author.id,
senderPublicId: author.id,
clientMsgId: "msg",
seq: 1n,
contentText: "hello",
reviewStatus: "REVIEWING",
sentAt: new Date(),
recalledAt: new Date(),
},
});
expect(conversation.lastMessageAt).toBeInstanceOf(Date);
expect(member).toMatchObject({
peerAliasSnapshot: "匿名海风",
blockedAt: null,
});
expect(message.seq).toBe(1n);
});
it("rejects duplicate message client ids within a conversation", async () => {
const author = await createAccount("message-client-id");
const bottle = await createBottle(author.id, "message-client-id");
const conversation = await prisma.conversation.create({
data: { sourceBottleId: bottle.id },
});
await expect(
prisma.message.create({
data: {
conversationId: conversation.id,
senderId: author.id,
senderPublicId: author.id,
clientMsgId: "duplicate-client-id",
seq: 1n,
contentText: "first",
},
}),
).resolves.toMatchObject({ clientMsgId: "duplicate-client-id", seq: 1n });
await expectUniqueViolation(
prisma.message.create({
data: {
conversationId: conversation.id,
senderId: author.id,
senderPublicId: author.id,
clientMsgId: "duplicate-client-id",
seq: 2n,
contentText: "second",
},
}),
);
});
it("rejects duplicate message sequences within a conversation", async () => {
const author = await createAccount("message-seq");
const bottle = await createBottle(author.id, "message-seq");
const conversation = await prisma.conversation.create({
data: { sourceBottleId: bottle.id },
});
await expect(
prisma.message.create({
data: {
conversationId: conversation.id,
senderId: author.id,
senderPublicId: author.id,
clientMsgId: "first-client-id",
seq: 1n,
contentText: "first",
},
}),
).resolves.toMatchObject({ clientMsgId: "first-client-id", seq: 1n });
await expectUniqueViolation(
prisma.message.create({
data: {
conversationId: conversation.id,
senderId: author.id,
senderPublicId: author.id,
clientMsgId: "second-client-id",
seq: 1n,
contentText: "second",
},
}),
);
});
it("rejects self-blocks", async () => {
const account = await createAccount("self-block");
await expectConstraintViolation(
prisma.block.create({
data: { blockerId: account.id, blockedId: account.id },
}),
);
});
it("rejects negative daily counters", async () => {
const account = await createAccount("negative-usage");
await expectConstraintViolation(
prisma.dailyUsage.create({
data: {
accountId: account.id,
usageDate: new Date(),
messagesSent: -1,
},
}),
);
});
it("rejects invalid conversation, member, message and outbox counters", async () => {
const author = await createAccount("counters");
const bottle = await createBottle(author.id, "counters");
await expectConstraintViolation(
prisma.conversation.create({
data: { sourceBottleId: bottle.id, nextSeq: 0n },
}),
);
const conversation = await prisma.conversation.create({
data: { sourceBottleId: bottle.id },
});
await expectConstraintViolation(
prisma.conversationMember.create({
data: {
conversationId: conversation.id,
accountId: author.id,
peerAliasSnapshot: "x",
lastReadSeq: -1n,
},
}),
);
await expectConstraintViolation(
prisma.message.create({
data: {
conversationId: conversation.id,
senderId: author.id,
senderPublicId: author.id,
clientMsgId: "zero",
seq: 0n,
contentText: "x",
},
}),
);
await expectConstraintViolation(
prisma.outboxEvent.create({
data: {
aggregateType: "Bottle",
aggregateId: bottle.id,
eventType: "created",
payload: {},
attempts: -1,
},
}),
);
});
it("requires reports to have exactly one target and stores snapshot/resolution", async () => {
const reporter = await createAccount("reporter");
const target = await createAccount("reported");
const bottle = await createBottle(target.id, "report");
await expectConstraintViolation(
prisma.report.create({
data: {
reporterId: reporter.id,
reason: "missing",
targetSnapshot: {},
},
}),
);
await expectConstraintViolation(
prisma.report.create({
data: {
reporterId: reporter.id,
reportedAccountId: target.id,
bottleId: bottle.id,
reason: "many",
targetSnapshot: {},
},
}),
);
await expect(
prisma.report.create({
data: {
reporterId: reporter.id,
bottleId: bottle.id,
reason: "one",
targetSnapshot: { text: "snapshot" },
resolution: "REMOVE",
},
}),
).resolves.toMatchObject({ resolution: "REMOVE" });
});
it("keeps the original authority uniqueness constraints", async () => {
const author = await createAccount("uniques-author");
const picker = await createAccount("uniques-picker");
const bottle = await createBottle(author.id, "uniques");
await prisma.conversation.create({ data: { sourceBottleId: bottle.id } });
await expectUniqueViolation(
prisma.conversation.create({ data: { sourceBottleId: bottle.id } }),
);
await prisma.bottlePickHistory.create({
data: { bottleId: bottle.id, pickerId: picker.id },
});
await expectUniqueViolation(
prisma.bottlePickHistory.create({
data: { bottleId: bottle.id, pickerId: picker.id },
}),
);
await prisma.block.create({
data: { blockerId: author.id, blockedId: picker.id },
});
await expectUniqueViolation(
prisma.block.create({
data: { blockerId: author.id, blockedId: picker.id },
}),
);
});
});
describe("PrismaService lifecycle", () => {
it("connects and disconnects through module lifecycle hooks", async () => {
const service = new PrismaService({
datasources: { db: { url: databaseUrl } },
});
await service.onModuleInit();
await expect(service.$queryRaw`SELECT 1`).resolves.toBeDefined();
await service.onModuleDestroy();
});
});