fix: 完善权威数据约束与测试隔离

This commit is contained in:
root
2026-09-14 11:34:07 +08:00
parent a2a5e4bd0f
commit c547e78a22
9 changed files with 600 additions and 160 deletions
+2 -2
View File
@@ -6,7 +6,7 @@ services:
POSTGRES_USER: ${POSTGRES_TEST_USER:-drift} POSTGRES_USER: ${POSTGRES_TEST_USER:-drift}
POSTGRES_PASSWORD: ${POSTGRES_TEST_PASSWORD:-drift_test_only} POSTGRES_PASSWORD: ${POSTGRES_TEST_PASSWORD:-drift_test_only}
ports: ports:
- "${POSTGRES_TEST_PORT:-55432}:5432" - "127.0.0.1:${POSTGRES_TEST_PORT:-55432}:5432"
healthcheck: healthcheck:
test: test:
[ [
@@ -21,7 +21,7 @@ services:
image: redis:7-alpine image: redis:7-alpine
command: ["redis-server", "--save", "", "--appendonly", "no"] command: ["redis-server", "--save", "", "--appendonly", "no"]
ports: ports:
- "${REDIS_TEST_PORT:-56379}:6379" - "127.0.0.1:${REDIS_TEST_PORT:-56379}:6379"
healthcheck: healthcheck:
test: ["CMD", "redis-cli", "ping"] test: ["CMD", "redis-cli", "ping"]
interval: 2s interval: 2s
+2 -1
View File
@@ -4,8 +4,9 @@
"private": true, "private": true,
"packageManager": "pnpm@9.15.0", "packageManager": "pnpm@9.15.0",
"scripts": { "scripts": {
"test": "vitest run", "test": "vitest run --no-file-parallelism",
"test:database": "vitest run tests/integration/database.spec.ts", "test:database": "vitest run tests/integration/database.spec.ts",
"test:integration": "vitest run tests/integration --no-file-parallelism",
"typecheck": "tsc --noEmit -p tsconfig.base.json", "typecheck": "tsc --noEmit -p tsconfig.base.json",
"lint": "eslint . --ext .ts --max-warnings 0 && prettier --check .", "lint": "eslint . --ext .ts --max-warnings 0 && prettier --check .",
"build": "corepack pnpm prisma generate && corepack pnpm --recursive run build", "build": "corepack pnpm prisma generate && corepack pnpm --recursive run build",
+37
View File
@@ -0,0 +1,37 @@
export const DEFAULT_TEST_DATABASE_URL =
"postgresql://drift:drift_test_only@127.0.0.1:55432/drift_bottle_test?schema=public";
const SAFETY_ERROR =
"Refusing database operation: DATABASE_URL must target localhost or 127.0.0.1 and a database ending in _test.";
export function assertSafeTestDatabaseUrl(databaseUrl: string): void {
try {
const parsed = new URL(databaseUrl);
const isLocal =
parsed.hostname === "localhost" || parsed.hostname === "127.0.0.1";
const databaseName = decodeURIComponent(parsed.pathname.slice(1));
if (
parsed.protocol !== "postgresql:" ||
!isLocal ||
!databaseName.endsWith("_test")
) {
throw new Error(SAFETY_ERROR);
}
} catch {
throw new Error(SAFETY_ERROR);
}
}
export function resolveTestDatabaseUrl(environment = process.env): string {
const databaseUrl = environment.DATABASE_URL ?? DEFAULT_TEST_DATABASE_URL;
assertSafeTestDatabaseUrl(databaseUrl);
return databaseUrl;
}
export function assertSeedDatabaseAllowed(
databaseUrl: string,
environment = process.env,
): void {
if (environment.ALLOW_SEED_NON_TEST_DATABASE === "true") return;
assertSafeTestDatabaseUrl(databaseUrl);
}
+74 -12
View File
@@ -5,7 +5,13 @@ CREATE TYPE "AccountStatus" AS ENUM ('ACTIVE', 'SUSPENDED', 'DELETED');
CREATE TYPE "AuthProvider" AS ENUM ('PHONE', 'APPLE', 'WECHAT'); CREATE TYPE "AuthProvider" AS ENUM ('PHONE', 'APPLE', 'WECHAT');
-- CreateEnum -- CreateEnum
CREATE TYPE "BottleStatus" AS ENUM ('DRAFT', 'IN_POOL', 'PICKED', 'EXPIRED', 'REMOVED'); CREATE TYPE "ReviewStatus" AS ENUM ('DRAFT', 'REVIEWING', 'REJECTED', 'MANUAL_REVIEW', 'APPROVED');
-- CreateEnum
CREATE TYPE "BottlePoolStatus" AS ENUM ('IN_POOL', 'LEASED', 'CONSUMED', 'REMOVED', 'CLOSED');
-- CreateEnum
CREATE TYPE "BottlePickLeaseStatus" AS ENUM ('ACTIVE', 'RETURNED', 'EXPIRED', 'CONSUMED');
-- CreateEnum -- CreateEnum
CREATE TYPE "ConversationStatus" AS ENUM ('ACTIVE', 'CLOSED'); CREATE TYPE "ConversationStatus" AS ENUM ('ACTIVE', 'CLOSED');
@@ -45,9 +51,12 @@ CREATE TABLE "accounts" (
CREATE TABLE "anonymous_profiles" ( CREATE TABLE "anonymous_profiles" (
"id" UUID NOT NULL, "id" UUID NOT NULL,
"account_id" UUID NOT NULL, "account_id" UUID NOT NULL,
"public_id" UUID NOT NULL,
"nickname" VARCHAR(64) NOT NULL, "nickname" VARCHAR(64) NOT NULL,
"avatar_key" VARCHAR(255), "avatar_key" VARCHAR(255),
"avatar_color" VARCHAR(16) NOT NULL,
"bio" VARCHAR(500), "bio" VARCHAR(500),
"review_status" "ReviewStatus" NOT NULL DEFAULT 'REVIEWING',
"created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ(3) NOT NULL, "updated_at" TIMESTAMPTZ(3) NOT NULL,
@@ -85,8 +94,11 @@ CREATE TABLE "bottles" (
"id" UUID NOT NULL, "id" UUID NOT NULL,
"author_id" UUID NOT NULL, "author_id" UUID NOT NULL,
"content_text" TEXT NOT NULL, "content_text" TEXT NOT NULL,
"status" "BottleStatus" NOT NULL DEFAULT 'DRAFT', "review_status" "ReviewStatus" NOT NULL DEFAULT 'DRAFT',
"picked_at" TIMESTAMPTZ(3), "pool_status" "BottlePoolStatus" NOT NULL DEFAULT 'CLOSED',
"version" INTEGER NOT NULL DEFAULT 1,
"approved_at" TIMESTAMPTZ(3),
"consumed_at" TIMESTAMPTZ(3),
"expires_at" TIMESTAMPTZ(3), "expires_at" TIMESTAMPTZ(3),
"created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ(3) NOT NULL, "updated_at" TIMESTAMPTZ(3) NOT NULL,
@@ -101,7 +113,8 @@ CREATE TABLE "bottle_pick_leases" (
"picker_id" UUID NOT NULL, "picker_id" UUID NOT NULL,
"lease_token_hash" VARCHAR(255) NOT NULL, "lease_token_hash" VARCHAR(255) NOT NULL,
"expires_at" TIMESTAMPTZ(3) NOT NULL, "expires_at" TIMESTAMPTZ(3) NOT NULL,
"consumed_at" TIMESTAMPTZ(3), "status" "BottlePickLeaseStatus" NOT NULL DEFAULT 'ACTIVE',
"ended_at" TIMESTAMPTZ(3),
"created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "bottle_pick_leases_pkey" PRIMARY KEY ("id") CONSTRAINT "bottle_pick_leases_pkey" PRIMARY KEY ("id")
@@ -136,7 +149,8 @@ CREATE TABLE "conversations" (
"id" UUID NOT NULL, "id" UUID NOT NULL,
"source_bottle_id" UUID NOT NULL, "source_bottle_id" UUID NOT NULL,
"status" "ConversationStatus" NOT NULL DEFAULT 'ACTIVE', "status" "ConversationStatus" NOT NULL DEFAULT 'ACTIVE',
"next_seq" INTEGER NOT NULL DEFAULT 1, "next_seq" BIGINT NOT NULL DEFAULT 1,
"last_message_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ(3) NOT NULL, "updated_at" TIMESTAMPTZ(3) NOT NULL,
@@ -150,7 +164,9 @@ CREATE TABLE "conversation_members" (
"account_id" UUID NOT NULL, "account_id" UUID NOT NULL,
"joined_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "joined_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"left_at" TIMESTAMPTZ(3), "left_at" TIMESTAMPTZ(3),
"last_read_seq" INTEGER NOT NULL DEFAULT 0, "last_read_seq" BIGINT NOT NULL DEFAULT 0,
"peer_alias_snapshot" VARCHAR(64) NOT NULL,
"blocked_at" TIMESTAMPTZ(3),
CONSTRAINT "conversation_members_pkey" PRIMARY KEY ("id") CONSTRAINT "conversation_members_pkey" PRIMARY KEY ("id")
); );
@@ -161,9 +177,12 @@ CREATE TABLE "messages" (
"conversation_id" UUID NOT NULL, "conversation_id" UUID NOT NULL,
"sender_id" UUID NOT NULL, "sender_id" UUID NOT NULL,
"client_msg_id" VARCHAR(128) NOT NULL, "client_msg_id" VARCHAR(128) NOT NULL,
"seq" INTEGER NOT NULL, "seq" BIGINT NOT NULL,
"content_text" TEXT NOT NULL, "content_text" TEXT NOT NULL,
"status" "MessageStatus" NOT NULL DEFAULT 'SENT', "status" "MessageStatus" NOT NULL DEFAULT 'SENT',
"review_status" "ReviewStatus" NOT NULL DEFAULT 'REVIEWING',
"sent_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"recalled_at" TIMESTAMPTZ(3),
"created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ(3) NOT NULL, "updated_at" TIMESTAMPTZ(3) NOT NULL,
@@ -190,6 +209,8 @@ CREATE TABLE "reports" (
"message_id" UUID, "message_id" UUID,
"reason" VARCHAR(100) NOT NULL, "reason" VARCHAR(100) NOT NULL,
"details" TEXT, "details" TEXT,
"target_snapshot" JSONB NOT NULL,
"resolution" TEXT,
"status" "ReportStatus" NOT NULL DEFAULT 'PENDING', "status" "ReportStatus" NOT NULL DEFAULT 'PENDING',
"created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ(3) NOT NULL, "updated_at" TIMESTAMPTZ(3) NOT NULL,
@@ -200,11 +221,18 @@ CREATE TABLE "reports" (
-- CreateTable -- CreateTable
CREATE TABLE "moderation_tasks" ( CREATE TABLE "moderation_tasks" (
"id" UUID NOT NULL, "id" UUID NOT NULL,
"report_id" UUID NOT NULL, "target_type" VARCHAR(50) NOT NULL,
"target_id" UUID NOT NULL,
"provider" VARCHAR(100) NOT NULL,
"result" JSONB,
"risk_labels" TEXT[],
"payload_hash" VARCHAR(128) NOT NULL,
"report_id" UUID,
"assigned_to_id" UUID, "assigned_to_id" UUID,
"status" "ModerationTaskStatus" NOT NULL DEFAULT 'PENDING', "status" "ModerationTaskStatus" NOT NULL DEFAULT 'PENDING',
"decision" TEXT, "decision" TEXT,
"created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"reviewed_at" TIMESTAMPTZ(3),
"updated_at" TIMESTAMPTZ(3) NOT NULL, "updated_at" TIMESTAMPTZ(3) NOT NULL,
CONSTRAINT "moderation_tasks_pkey" PRIMARY KEY ("id") CONSTRAINT "moderation_tasks_pkey" PRIMARY KEY ("id")
@@ -250,7 +278,7 @@ CREATE TABLE "outbox_events" (
"payload" JSONB NOT NULL, "payload" JSONB NOT NULL,
"status" "OutboxStatus" NOT NULL DEFAULT 'PENDING', "status" "OutboxStatus" NOT NULL DEFAULT 'PENDING',
"attempts" INTEGER NOT NULL DEFAULT 0, "attempts" INTEGER NOT NULL DEFAULT 0,
"available_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "next_retry_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"published_at" TIMESTAMPTZ(3), "published_at" TIMESTAMPTZ(3),
"created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ(3) NOT NULL, "updated_at" TIMESTAMPTZ(3) NOT NULL,
@@ -279,6 +307,9 @@ CREATE UNIQUE INDEX "accounts_phone_hmac_key" ON "accounts"("phone_hmac");
-- CreateIndex -- CreateIndex
CREATE UNIQUE INDEX "anonymous_profiles_account_id_key" ON "anonymous_profiles"("account_id"); CREATE UNIQUE INDEX "anonymous_profiles_account_id_key" ON "anonymous_profiles"("account_id");
-- CreateIndex
CREATE UNIQUE INDEX "anonymous_profiles_public_id_key" ON "anonymous_profiles"("public_id");
-- CreateIndex -- CreateIndex
CREATE INDEX "auth_identities_account_id_idx" ON "auth_identities"("account_id"); CREATE INDEX "auth_identities_account_id_idx" ON "auth_identities"("account_id");
@@ -292,7 +323,10 @@ CREATE UNIQUE INDEX "sessions_refresh_token_hash_key" ON "sessions"("refresh_tok
CREATE INDEX "sessions_account_id_expires_at_idx" ON "sessions"("account_id", "expires_at"); CREATE INDEX "sessions_account_id_expires_at_idx" ON "sessions"("account_id", "expires_at");
-- CreateIndex -- CreateIndex
CREATE INDEX "bottles_status_created_at_idx" ON "bottles"("status", "created_at"); CREATE INDEX "bottles_pool_status_created_at_idx" ON "bottles"("pool_status", "created_at");
-- CreateIndex
CREATE INDEX "bottles_review_status_created_at_idx" ON "bottles"("review_status", "created_at");
-- CreateIndex -- CreateIndex
CREATE INDEX "bottles_author_id_created_at_idx" ON "bottles"("author_id", "created_at"); CREATE INDEX "bottles_author_id_created_at_idx" ON "bottles"("author_id", "created_at");
@@ -354,6 +388,9 @@ CREATE INDEX "reports_reported_account_id_created_at_idx" ON "reports"("reported
-- CreateIndex -- CreateIndex
CREATE INDEX "moderation_tasks_status_created_at_idx" ON "moderation_tasks"("status", "created_at"); CREATE INDEX "moderation_tasks_status_created_at_idx" ON "moderation_tasks"("status", "created_at");
-- CreateIndex
CREATE INDEX "moderation_tasks_target_type_target_id_idx" ON "moderation_tasks"("target_type", "target_id");
-- CreateIndex -- CreateIndex
CREATE INDEX "moderation_tasks_assigned_to_id_status_idx" ON "moderation_tasks"("assigned_to_id", "status"); CREATE INDEX "moderation_tasks_assigned_to_id_status_idx" ON "moderation_tasks"("assigned_to_id", "status");
@@ -364,7 +401,7 @@ CREATE INDEX "sanctions_account_id_expires_at_idx" ON "sanctions"("account_id",
CREATE INDEX "notifications_account_id_status_created_at_idx" ON "notifications"("account_id", "status", "created_at"); CREATE INDEX "notifications_account_id_status_created_at_idx" ON "notifications"("account_id", "status", "created_at");
-- CreateIndex -- CreateIndex
CREATE INDEX "outbox_events_status_available_at_idx" ON "outbox_events"("status", "available_at"); CREATE INDEX "outbox_events_status_next_retry_at_idx" ON "outbox_events"("status", "next_retry_at");
-- CreateIndex -- CreateIndex
CREATE INDEX "outbox_events_aggregate_type_aggregate_id_idx" ON "outbox_events"("aggregate_type", "aggregate_id"); CREATE INDEX "outbox_events_aggregate_type_aggregate_id_idx" ON "outbox_events"("aggregate_type", "aggregate_id");
@@ -442,7 +479,7 @@ ALTER TABLE "reports" ADD CONSTRAINT "reports_conversation_id_fkey" FOREIGN KEY
ALTER TABLE "reports" ADD CONSTRAINT "reports_message_id_fkey" FOREIGN KEY ("message_id") REFERENCES "messages"("id") ON DELETE SET NULL ON UPDATE CASCADE; ALTER TABLE "reports" ADD CONSTRAINT "reports_message_id_fkey" FOREIGN KEY ("message_id") REFERENCES "messages"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey -- AddForeignKey
ALTER TABLE "moderation_tasks" ADD CONSTRAINT "moderation_tasks_report_id_fkey" FOREIGN KEY ("report_id") REFERENCES "reports"("id") ON DELETE CASCADE ON UPDATE CASCADE; ALTER TABLE "moderation_tasks" ADD CONSTRAINT "moderation_tasks_report_id_fkey" FOREIGN KEY ("report_id") REFERENCES "reports"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey -- AddForeignKey
ALTER TABLE "moderation_tasks" ADD CONSTRAINT "moderation_tasks_assigned_to_id_fkey" FOREIGN KEY ("assigned_to_id") REFERENCES "accounts"("id") ON DELETE SET NULL ON UPDATE CASCADE; ALTER TABLE "moderation_tasks" ADD CONSTRAINT "moderation_tasks_assigned_to_id_fkey" FOREIGN KEY ("assigned_to_id") REFERENCES "accounts"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -461,3 +498,28 @@ ALTER TABLE "notifications" ADD CONSTRAINT "notifications_account_id_fkey" FOREI
-- AddForeignKey -- AddForeignKey
ALTER TABLE "audit_logs" ADD CONSTRAINT "audit_logs_actor_id_fkey" FOREIGN KEY ("actor_id") REFERENCES "accounts"("id") ON DELETE SET NULL ON UPDATE CASCADE; ALTER TABLE "audit_logs" ADD CONSTRAINT "audit_logs_actor_id_fkey" FOREIGN KEY ("actor_id") REFERENCES "accounts"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- Database-only invariants unsupported by Prisma schema syntax.
CREATE UNIQUE INDEX "bottle_pick_leases_one_active_per_bottle"
ON "bottle_pick_leases"("bottle_id") WHERE "status" = 'ACTIVE';
ALTER TABLE "blocks"
ADD CONSTRAINT "blocks_no_self_block" CHECK ("blocker_id" <> "blocked_id");
ALTER TABLE "daily_usage"
ADD CONSTRAINT "daily_usage_nonnegative" CHECK (
"bottles_created" >= 0 AND "bottles_picked" >= 0 AND "messages_sent" >= 0
);
ALTER TABLE "conversations"
ADD CONSTRAINT "conversations_next_seq_positive" CHECK ("next_seq" >= 1);
ALTER TABLE "conversation_members"
ADD CONSTRAINT "conversation_members_last_read_seq_nonnegative" CHECK ("last_read_seq" >= 0);
ALTER TABLE "messages"
ADD CONSTRAINT "messages_seq_positive" CHECK ("seq" >= 1);
ALTER TABLE "outbox_events"
ADD CONSTRAINT "outbox_events_attempts_nonnegative" CHECK ("attempts" >= 0);
ALTER TABLE "reports"
ADD CONSTRAINT "reports_exactly_one_target" CHECK (
num_nonnulls("reported_account_id", "bottle_id", "conversation_id", "message_id") = 1
);
+79 -37
View File
@@ -19,12 +19,27 @@ enum AuthProvider {
WECHAT WECHAT
} }
enum BottleStatus { enum ReviewStatus {
DRAFT DRAFT
REVIEWING
REJECTED
MANUAL_REVIEW
APPROVED
}
enum BottlePoolStatus {
IN_POOL IN_POOL
PICKED LEASED
EXPIRED CONSUMED
REMOVED REMOVED
CLOSED
}
enum BottlePickLeaseStatus {
ACTIVE
RETURNED
EXPIRED
CONSUMED
} }
enum ConversationStatus { enum ConversationStatus {
@@ -103,14 +118,17 @@ model Account {
} }
model AnonymousProfile { model AnonymousProfile {
id String @id @default(uuid()) @db.Uuid id String @id @default(uuid()) @db.Uuid
accountId String @unique @map("account_id") @db.Uuid accountId String @unique @map("account_id") @db.Uuid
nickname String @db.VarChar(64) publicId String @unique @default(uuid()) @map("public_id") @db.Uuid
avatarKey String? @map("avatar_key") @db.VarChar(255) nickname String @db.VarChar(64)
bio String? @db.VarChar(500) avatarKey String? @map("avatar_key") @db.VarChar(255)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) avatarColor String @map("avatar_color") @db.VarChar(16)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3) bio String? @db.VarChar(500)
account Account @relation(fields: [accountId], references: [id], onDelete: Cascade) reviewStatus ReviewStatus @default(REVIEWING) @map("review_status")
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
account Account @relation(fields: [accountId], references: [id], onDelete: Cascade)
@@map("anonymous_profiles") @@map("anonymous_profiles")
} }
@@ -148,8 +166,11 @@ model Bottle {
id String @id @default(uuid()) @db.Uuid id String @id @default(uuid()) @db.Uuid
authorId String @map("author_id") @db.Uuid authorId String @map("author_id") @db.Uuid
contentText String @map("content_text") @db.Text contentText String @map("content_text") @db.Text
status BottleStatus @default(DRAFT) reviewStatus ReviewStatus @default(DRAFT) @map("review_status")
pickedAt DateTime? @map("picked_at") @db.Timestamptz(3) poolStatus BottlePoolStatus @default(CLOSED) @map("pool_status")
version Int @default(1)
approvedAt DateTime? @map("approved_at") @db.Timestamptz(3)
consumedAt DateTime? @map("consumed_at") @db.Timestamptz(3)
expiresAt DateTime? @map("expires_at") @db.Timestamptz(3) expiresAt DateTime? @map("expires_at") @db.Timestamptz(3)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3) updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
@@ -159,21 +180,24 @@ model Bottle {
conversation Conversation? conversation Conversation?
reports Report[] reports Report[]
@@index([status, createdAt]) @@index([poolStatus, createdAt])
@@index([reviewStatus, createdAt])
@@index([authorId, createdAt]) @@index([authorId, createdAt])
@@map("bottles") @@map("bottles")
} }
model BottlePickLease { model BottlePickLease {
id String @id @default(uuid()) @db.Uuid /// Partial unique index bottle_pick_leases_one_active_per_bottle is managed in 0001_init SQL.
bottleId String @map("bottle_id") @db.Uuid id String @id @default(uuid()) @db.Uuid
pickerId String @map("picker_id") @db.Uuid bottleId String @map("bottle_id") @db.Uuid
leaseTokenHash String @unique @map("lease_token_hash") @db.VarChar(255) pickerId String @map("picker_id") @db.Uuid
expiresAt DateTime @map("expires_at") @db.Timestamptz(3) leaseTokenHash String @unique @map("lease_token_hash") @db.VarChar(255)
consumedAt DateTime? @map("consumed_at") @db.Timestamptz(3) expiresAt DateTime @map("expires_at") @db.Timestamptz(3)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) status BottlePickLeaseStatus @default(ACTIVE)
bottle Bottle @relation(fields: [bottleId], references: [id], onDelete: Cascade) endedAt DateTime? @map("ended_at") @db.Timestamptz(3)
picker Account @relation("PickerLeases", fields: [pickerId], references: [id], onDelete: Cascade) createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
bottle Bottle @relation(fields: [bottleId], references: [id], onDelete: Cascade)
picker Account @relation("PickerLeases", fields: [pickerId], references: [id], onDelete: Cascade)
@@index([pickerId, expiresAt]) @@index([pickerId, expiresAt])
@@index([bottleId, expiresAt]) @@index([bottleId, expiresAt])
@@ -194,6 +218,7 @@ model BottlePickHistory {
} }
model DailyUsage { model DailyUsage {
/// Nonnegative counters are enforced by CHECK constraints in 0001_init SQL.
id String @id @default(uuid()) @db.Uuid id String @id @default(uuid()) @db.Uuid
accountId String @map("account_id") @db.Uuid accountId String @map("account_id") @db.Uuid
usageDate DateTime @map("usage_date") @db.Date usageDate DateTime @map("usage_date") @db.Date
@@ -213,7 +238,8 @@ model Conversation {
id String @id @default(uuid()) @db.Uuid id String @id @default(uuid()) @db.Uuid
sourceBottleId String @unique @map("source_bottle_id") @db.Uuid sourceBottleId String @unique @map("source_bottle_id") @db.Uuid
status ConversationStatus @default(ACTIVE) status ConversationStatus @default(ACTIVE)
nextSeq Int @default(1) @map("next_seq") nextSeq BigInt @default(1) @map("next_seq")
lastMessageAt DateTime @default(now()) @map("last_message_at") @db.Timestamptz(3)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3) updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
sourceBottle Bottle @relation(fields: [sourceBottleId], references: [id], onDelete: Restrict) sourceBottle Bottle @relation(fields: [sourceBottleId], references: [id], onDelete: Restrict)
@@ -226,14 +252,16 @@ model Conversation {
} }
model ConversationMember { model ConversationMember {
id String @id @default(uuid()) @db.Uuid id String @id @default(uuid()) @db.Uuid
conversationId String @map("conversation_id") @db.Uuid conversationId String @map("conversation_id") @db.Uuid
accountId String @map("account_id") @db.Uuid accountId String @map("account_id") @db.Uuid
joinedAt DateTime @default(now()) @map("joined_at") @db.Timestamptz(3) joinedAt DateTime @default(now()) @map("joined_at") @db.Timestamptz(3)
leftAt DateTime? @map("left_at") @db.Timestamptz(3) leftAt DateTime? @map("left_at") @db.Timestamptz(3)
lastReadSeq Int @default(0) @map("last_read_seq") lastReadSeq BigInt @default(0) @map("last_read_seq")
conversation Conversation @relation(fields: [conversationId], references: [id], onDelete: Cascade) peerAliasSnapshot String @map("peer_alias_snapshot") @db.VarChar(64)
account Account @relation(fields: [accountId], references: [id], onDelete: Cascade) blockedAt DateTime? @map("blocked_at") @db.Timestamptz(3)
conversation Conversation @relation(fields: [conversationId], references: [id], onDelete: Cascade)
account Account @relation(fields: [accountId], references: [id], onDelete: Cascade)
@@unique([conversationId, accountId]) @@unique([conversationId, accountId])
@@index([accountId, joinedAt]) @@index([accountId, joinedAt])
@@ -245,9 +273,12 @@ model Message {
conversationId String @map("conversation_id") @db.Uuid conversationId String @map("conversation_id") @db.Uuid
senderId String @map("sender_id") @db.Uuid senderId String @map("sender_id") @db.Uuid
clientMsgId String @map("client_msg_id") @db.VarChar(128) clientMsgId String @map("client_msg_id") @db.VarChar(128)
seq Int seq BigInt
contentText String @map("content_text") @db.Text contentText String @map("content_text") @db.Text
status MessageStatus @default(SENT) status MessageStatus @default(SENT)
reviewStatus ReviewStatus @default(REVIEWING) @map("review_status")
sentAt DateTime @default(now()) @map("sent_at") @db.Timestamptz(3)
recalledAt DateTime? @map("recalled_at") @db.Timestamptz(3)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3) updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
conversation Conversation @relation(fields: [conversationId], references: [id], onDelete: Cascade) conversation Conversation @relation(fields: [conversationId], references: [id], onDelete: Cascade)
@@ -274,6 +305,7 @@ model Block {
} }
model Report { model Report {
/// Exactly one nullable target foreign key must be set; enforced in 0001_init SQL.
id String @id @default(uuid()) @db.Uuid id String @id @default(uuid()) @db.Uuid
reporterId String @map("reporter_id") @db.Uuid reporterId String @map("reporter_id") @db.Uuid
reportedAccountId String? @map("reported_account_id") @db.Uuid reportedAccountId String? @map("reported_account_id") @db.Uuid
@@ -282,6 +314,8 @@ model Report {
messageId String? @map("message_id") @db.Uuid messageId String? @map("message_id") @db.Uuid
reason String @db.VarChar(100) reason String @db.VarChar(100)
details String? @db.Text details String? @db.Text
targetSnapshot Json @map("target_snapshot")
resolution String? @db.Text
status ReportStatus @default(PENDING) status ReportStatus @default(PENDING)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3) updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
@@ -299,17 +333,25 @@ model Report {
model ModerationTask { model ModerationTask {
id String @id @default(uuid()) @db.Uuid id String @id @default(uuid()) @db.Uuid
reportId String @map("report_id") @db.Uuid targetType String @map("target_type") @db.VarChar(50)
targetId String @map("target_id") @db.Uuid
provider String @db.VarChar(100)
result Json?
riskLabels String[] @map("risk_labels")
payloadHash String @map("payload_hash") @db.VarChar(128)
reportId String? @map("report_id") @db.Uuid
assignedToId String? @map("assigned_to_id") @db.Uuid assignedToId String? @map("assigned_to_id") @db.Uuid
status ModerationTaskStatus @default(PENDING) status ModerationTaskStatus @default(PENDING)
decision String? @db.Text decision String? @db.Text
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
reviewedAt DateTime? @map("reviewed_at") @db.Timestamptz(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3) updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
report Report @relation(fields: [reportId], references: [id], onDelete: Cascade) report Report? @relation(fields: [reportId], references: [id], onDelete: SetNull)
assignedTo Account? @relation("Moderator", fields: [assignedToId], references: [id], onDelete: SetNull) assignedTo Account? @relation("Moderator", fields: [assignedToId], references: [id], onDelete: SetNull)
sanctions Sanction[] sanctions Sanction[]
@@index([status, createdAt]) @@index([status, createdAt])
@@index([targetType, targetId])
@@index([assignedToId, status]) @@index([assignedToId, status])
@@map("moderation_tasks") @@map("moderation_tasks")
} }
@@ -357,12 +399,12 @@ model OutboxEvent {
payload Json payload Json
status OutboxStatus @default(PENDING) status OutboxStatus @default(PENDING)
attempts Int @default(0) attempts Int @default(0)
availableAt DateTime @default(now()) @map("available_at") @db.Timestamptz(3) nextRetryAt DateTime @default(now()) @map("next_retry_at") @db.Timestamptz(3)
publishedAt DateTime? @map("published_at") @db.Timestamptz(3) publishedAt DateTime? @map("published_at") @db.Timestamptz(3)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3) updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
@@index([status, availableAt]) @@index([status, nextRetryAt])
@@index([aggregateType, aggregateId]) @@index([aggregateType, aggregateId])
@@map("outbox_events") @@map("outbox_events")
} }
+50 -22
View File
@@ -1,20 +1,46 @@
import { createHash } from "node:crypto"; import { createHash } from "node:crypto";
import { PrismaClient } from "@prisma/client"; import { PrismaClient } from "@prisma/client";
import {
assertSeedDatabaseAllowed,
DEFAULT_TEST_DATABASE_URL,
} from "./database-safety";
const prisma = new PrismaClient(); const databaseUrl = process.env.DATABASE_URL ?? DEFAULT_TEST_DATABASE_URL;
assertSeedDatabaseAllowed(databaseUrl);
const prisma = new PrismaClient({ datasources: { db: { url: databaseUrl } } });
const id = (value: string) => createHash("sha256").update(value).digest("hex"); const id = (value: string) => createHash("sha256").update(value).digest("hex");
async function seed(): Promise<void> { async function seed(): Promise<void> {
const accounts = await Promise.all( const accounts = await Promise.all(
[ [
{ key: "demo-a", nickname: "海风" }, {
{ key: "demo-b", nickname: "星河" }, key: "demo-a",
].map(({ key, nickname }) => nickname: "海风",
publicId: "00000000-0000-4000-8000-000000000001",
},
{
key: "demo-b",
nickname: "星河",
publicId: "00000000-0000-4000-8000-000000000002",
},
].map(({ key, nickname, publicId }) =>
prisma.account.upsert({ prisma.account.upsert({
where: { phoneHmac: id(`drift-bottle:${key}:phone-hmac`) }, where: { phoneHmac: id(`drift-bottle:${key}:phone-hmac`) },
update: { update: {
anonymousProfile: { anonymousProfile: {
upsert: { create: { nickname }, update: { nickname } }, upsert: {
create: {
publicId,
nickname,
avatarColor: "#66CCFF",
reviewStatus: "APPROVED",
},
update: {
nickname,
avatarColor: "#66CCFF",
reviewStatus: "APPROVED",
},
},
}, },
}, },
create: { create: {
@@ -23,7 +49,14 @@ async function seed(): Promise<void> {
"hex", "hex",
), ),
phoneHmac: id(`drift-bottle:${key}:phone-hmac`), phoneHmac: id(`drift-bottle:${key}:phone-hmac`),
anonymousProfile: { create: { nickname } }, anonymousProfile: {
create: {
publicId,
nickname,
avatarColor: "#66CCFF",
reviewStatus: "APPROVED",
},
},
}, },
}), }),
), ),
@@ -31,20 +64,17 @@ async function seed(): Promise<void> {
const author = accounts[0]; const author = accounts[0];
if (!author) throw new Error("Seed author was not created"); if (!author) throw new Error("Seed author was not created");
const bottleData = {
authorId: author.id,
contentText: "愿你今天遇见温柔。",
reviewStatus: "APPROVED" as const,
poolStatus: "IN_POOL" as const,
approvedAt: new Date(0),
};
await prisma.bottle.upsert({ await prisma.bottle.upsert({
where: { id: "00000000-0000-4000-8000-000000000101" }, where: { id: "00000000-0000-4000-8000-000000000101" },
update: { update: bottleData,
authorId: author.id, create: { id: "00000000-0000-4000-8000-000000000101", ...bottleData },
contentText: "愿你今天遇见温柔。",
status: "IN_POOL",
},
create: {
id: "00000000-0000-4000-8000-000000000101",
authorId: author.id,
contentText: "愿你今天遇见温柔。",
status: "IN_POOL",
},
}); });
console.info( console.info(
@@ -54,10 +84,8 @@ async function seed(): Promise<void> {
seed() seed()
.catch((error: unknown) => { .catch((error: unknown) => {
console.error( void error;
"Seed failed", console.error("Seed failed.");
error instanceof Error ? error.message : "unknown error",
);
process.exitCode = 1; process.exitCode = 1;
}) })
.finally(async () => prisma.$disconnect()); .finally(async () => prisma.$disconnect());
+40
View File
@@ -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();
});
});
+283 -86
View File
@@ -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 { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
import { PrismaService } from "../../apps/api/src/database/prisma.service"; import { PrismaService } from "../../apps/api/src/database/prisma.service";
import {
assertSafeTestDatabaseUrl,
resolveTestDatabaseUrl,
} from "../../prisma/database-safety";
const databaseUrl = const databaseUrl = resolveTestDatabaseUrl();
process.env.DATABASE_URL ??
"postgresql://drift:drift_test_only@127.0.0.1:55432/drift_bottle_test?schema=public";
const prisma = new PrismaClient({ datasources: { db: { url: databaseUrl } } }); 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({ await expect(operation).rejects.toMatchObject({
code: "P2002", name: "PrismaClientUnknownRequestError",
} satisfies Partial<Prisma.PrismaClientKnownRequestError>); });
}
async function expectUniqueViolation(operation: Promise<unknown>) {
await expect(operation).rejects.toMatchObject({ code: "P2002" });
} }
async function createAccount(suffix: string) { async function createAccount(suffix: string) {
@@ -18,117 +25,307 @@ async function createAccount(suffix: string) {
data: { data: {
phoneCiphertext: Buffer.from(`ciphertext-${suffix}`), phoneCiphertext: Buffer.from(`ciphertext-${suffix}`),
phoneHmac: `hmac-${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) { async function createBottle(authorId: string, suffix: string) {
return prisma.bottle.create({ 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", () => { describe("database authority constraints", () => {
beforeAll(async () => prisma.$connect()); beforeAll(async () => {
assertSafeTestDatabaseUrl(databaseUrl);
await prisma.$connect();
});
beforeEach(async () => { beforeEach(async () => {
assertSafeTestDatabaseUrl(databaseUrl);
await prisma.$executeRawUnsafe(`TRUNCATE TABLE "accounts" CASCADE`); await prisma.$executeRawUnsafe(`TRUNCATE TABLE "accounts" CASCADE`);
}); });
afterAll(async () => prisma.$disconnect()); afterAll(async () => prisma.$disconnect());
it("rejects a second conversation for the same source bottle with P2002", async () => { it("expresses independent moderation and pool lifecycle state", async () => {
const author = await createAccount("conversation-author"); const author = await createAccount("states");
const bottle = await createBottle(author.id, "conversation"); const bottle = await prisma.bottle.create({
await prisma.conversation.create({ data: { sourceBottleId: bottle.id } }); 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( 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 () => { it("allows only one ACTIVE lease per bottle and allows ended leases", async () => {
const author = await createAccount("history-author"); const author = await createAccount("lease-author");
const picker = await createAccount("history-picker"); const firstPicker = await createAccount("lease-picker-a");
const bottle = await createBottle(author.id, "history"); 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({ await prisma.bottlePickHistory.create({
data: { bottleId: bottle.id, pickerId: picker.id }, data: { bottleId: bottle.id, pickerId: picker.id },
}); });
await expectUniqueViolation( await expectUniqueViolation(
prisma.bottlePickHistory.create({ prisma.bottlePickHistory.create({
data: { bottleId: bottle.id, pickerId: picker.id }, 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({ await prisma.block.create({
data: { blockerId: blocker.id, blockedId: blocked.id }, data: { blockerId: author.id, blockedId: picker.id },
}); });
await expectUniqueViolation( await expectUniqueViolation(
prisma.block.create({ prisma.block.create({
data: { blockerId: blocker.id, blockedId: blocked.id }, data: { blockerId: author.id, blockedId: picker.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: "二",
},
}), }),
); );
}); });
+33
View File
@@ -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]);
});
});