fix: 完善权威数据约束与测试隔离
This commit is contained in:
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
assertSafeTestDatabaseUrl,
|
||||
assertSeedDatabaseAllowed,
|
||||
} from "../../prisma/database-safety";
|
||||
|
||||
describe("test database safety guard", () => {
|
||||
it.each([
|
||||
"postgresql://drift:secret@db.example.com/app_test",
|
||||
"postgresql://drift:secret@127.0.0.1/production",
|
||||
"not a url",
|
||||
])("rejects unsafe DATABASE_URL without exposing it: %s", (url) => {
|
||||
expect(() => assertSafeTestDatabaseUrl(url)).toThrowError(
|
||||
"Refusing database operation: DATABASE_URL must target localhost or 127.0.0.1 and a database ending in _test.",
|
||||
);
|
||||
try {
|
||||
assertSafeTestDatabaseUrl(url);
|
||||
} catch (error) {
|
||||
expect(String(error)).not.toContain(url);
|
||||
expect(String(error)).not.toContain("secret");
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
"postgresql://drift:secret@localhost:55432/drift_bottle_test?schema=public",
|
||||
"postgresql://drift:secret@127.0.0.1:55432/drift_bottle_test",
|
||||
])("accepts an isolated local test database: %s", (url) => {
|
||||
expect(() => assertSafeTestDatabaseUrl(url)).not.toThrow();
|
||||
});
|
||||
|
||||
it("requires explicit authorization before seeding a non-test database", () => {
|
||||
const productionUrl = "postgresql://drift:secret@db.example.com/production";
|
||||
expect(() => assertSeedDatabaseAllowed(productionUrl, {})).toThrow();
|
||||
expect(() =>
|
||||
assertSeedDatabaseAllowed(productionUrl, {
|
||||
ALLOW_SEED_NON_TEST_DATABASE: "true",
|
||||
}),
|
||||
).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -1,16 +1,23 @@
|
||||
import { Prisma, PrismaClient } from "@prisma/client";
|
||||
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 =
|
||||
process.env.DATABASE_URL ??
|
||||
"postgresql://drift:drift_test_only@127.0.0.1:55432/drift_bottle_test?schema=public";
|
||||
const databaseUrl = resolveTestDatabaseUrl();
|
||||
const prisma = new PrismaClient({ datasources: { db: { url: databaseUrl } } });
|
||||
|
||||
async function expectUniqueViolation(operation: Promise<unknown>) {
|
||||
async function expectConstraintViolation(operation: Promise<unknown>) {
|
||||
await expect(operation).rejects.toMatchObject({
|
||||
code: "P2002",
|
||||
} satisfies Partial<Prisma.PrismaClientKnownRequestError>);
|
||||
name: "PrismaClientUnknownRequestError",
|
||||
});
|
||||
}
|
||||
|
||||
async function expectUniqueViolation(operation: Promise<unknown>) {
|
||||
await expect(operation).rejects.toMatchObject({ code: "P2002" });
|
||||
}
|
||||
|
||||
async function createAccount(suffix: string) {
|
||||
@@ -18,117 +25,307 @@ async function createAccount(suffix: string) {
|
||||
data: {
|
||||
phoneCiphertext: Buffer.from(`ciphertext-${suffix}`),
|
||||
phoneHmac: `hmac-${suffix}`,
|
||||
anonymousProfile: { create: { nickname: `漂友-${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}`, status: "IN_POOL" },
|
||||
data: {
|
||||
authorId,
|
||||
contentText: `测试瓶子-${suffix}`,
|
||||
reviewStatus: "APPROVED",
|
||||
poolStatus: "IN_POOL",
|
||||
version: 1,
|
||||
approvedAt: new Date(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
describe("database authority constraints", () => {
|
||||
beforeAll(async () => prisma.$connect());
|
||||
beforeAll(async () => {
|
||||
assertSafeTestDatabaseUrl(databaseUrl);
|
||||
await prisma.$connect();
|
||||
});
|
||||
beforeEach(async () => {
|
||||
assertSafeTestDatabaseUrl(databaseUrl);
|
||||
await prisma.$executeRawUnsafe(`TRUNCATE TABLE "accounts" CASCADE`);
|
||||
});
|
||||
afterAll(async () => prisma.$disconnect());
|
||||
|
||||
it("rejects a second conversation for the same source bottle with P2002", async () => {
|
||||
const author = await createAccount("conversation-author");
|
||||
const bottle = await createBottle(author.id, "conversation");
|
||||
await prisma.conversation.create({ data: { sourceBottleId: bottle.id } });
|
||||
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.conversation.create({ data: { sourceBottleId: bottle.id } }),
|
||||
prisma.account.create({
|
||||
data: {
|
||||
phoneCiphertext: Buffer.from("other"),
|
||||
phoneHmac: "other-public-id",
|
||||
anonymousProfile: {
|
||||
create: {
|
||||
publicId,
|
||||
nickname: "另一位",
|
||||
avatarColor: "#000000",
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects duplicate bottle pick history with P2002", async () => {
|
||||
const author = await createAccount("history-author");
|
||||
const picker = await createAccount("history-picker");
|
||||
const bottle = await createBottle(author.id, "history");
|
||||
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",
|
||||
expiresAt: new Date(Date.now() + 60_000),
|
||||
},
|
||||
});
|
||||
await expectUniqueViolation(
|
||||
prisma.bottlePickLease.create({
|
||||
data: {
|
||||
bottleId: bottle.id,
|
||||
pickerId: secondPicker.id,
|
||||
leaseTokenHash: "lease-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",
|
||||
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,
|
||||
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 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,
|
||||
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 },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects duplicate directed blocks with P2002", async () => {
|
||||
const blocker = await createAccount("blocker");
|
||||
const blocked = await createAccount("blocked");
|
||||
await prisma.block.create({
|
||||
data: { blockerId: blocker.id, blockedId: blocked.id },
|
||||
data: { blockerId: author.id, blockedId: picker.id },
|
||||
});
|
||||
|
||||
await expectUniqueViolation(
|
||||
prisma.block.create({
|
||||
data: { blockerId: blocker.id, blockedId: blocked.id },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects duplicate client message ids within a conversation with P2002", async () => {
|
||||
const author = await createAccount("client-message-author");
|
||||
const bottle = await createBottle(author.id, "client-message");
|
||||
const conversation = await prisma.conversation.create({
|
||||
data: { sourceBottleId: bottle.id },
|
||||
});
|
||||
await prisma.message.create({
|
||||
data: {
|
||||
conversationId: conversation.id,
|
||||
senderId: author.id,
|
||||
clientMsgId: "client-1",
|
||||
seq: 1,
|
||||
contentText: "一",
|
||||
},
|
||||
});
|
||||
|
||||
await expectUniqueViolation(
|
||||
prisma.message.create({
|
||||
data: {
|
||||
conversationId: conversation.id,
|
||||
senderId: author.id,
|
||||
clientMsgId: "client-1",
|
||||
seq: 2,
|
||||
contentText: "二",
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects duplicate message sequences within a conversation with P2002", async () => {
|
||||
const author = await createAccount("sequence-author");
|
||||
const bottle = await createBottle(author.id, "sequence");
|
||||
const conversation = await prisma.conversation.create({
|
||||
data: { sourceBottleId: bottle.id },
|
||||
});
|
||||
await prisma.message.create({
|
||||
data: {
|
||||
conversationId: conversation.id,
|
||||
senderId: author.id,
|
||||
clientMsgId: "client-a",
|
||||
seq: 1,
|
||||
contentText: "一",
|
||||
},
|
||||
});
|
||||
|
||||
await expectUniqueViolation(
|
||||
prisma.message.create({
|
||||
data: {
|
||||
conversationId: conversation.id,
|
||||
senderId: author.id,
|
||||
clientMsgId: "client-b",
|
||||
seq: 1,
|
||||
contentText: "二",
|
||||
},
|
||||
data: { blockerId: author.id, blockedId: picker.id },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { resolveTestDatabaseUrl } from "../../prisma/database-safety";
|
||||
|
||||
const databaseUrl = resolveTestDatabaseUrl();
|
||||
const prisma = new PrismaClient({ datasources: { db: { url: databaseUrl } } });
|
||||
|
||||
describe("seed", () => {
|
||||
beforeAll(async () => {
|
||||
await prisma.$connect();
|
||||
await prisma.$executeRawUnsafe(`TRUNCATE TABLE "accounts" CASCADE`);
|
||||
});
|
||||
afterAll(async () => prisma.$disconnect());
|
||||
|
||||
it("is idempotent and leaves exact fixture counts", async () => {
|
||||
const runSeed = () =>
|
||||
execFileSync("corepack", ["pnpm", "prisma:seed"], {
|
||||
cwd: process.cwd(),
|
||||
env: { ...process.env, DATABASE_URL: databaseUrl },
|
||||
encoding: "utf8",
|
||||
});
|
||||
expect(runSeed()).toContain("Seed complete");
|
||||
expect(runSeed()).toContain("Seed complete");
|
||||
await expect(
|
||||
Promise.all([
|
||||
prisma.account.count(),
|
||||
prisma.anonymousProfile.count(),
|
||||
prisma.bottle.count({ where: { poolStatus: "IN_POOL" } }),
|
||||
]),
|
||||
).resolves.toEqual([2, 2, 1]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user