diff --git a/infra/compose.test.yaml b/infra/compose.test.yaml index ea4e5a8..e26913f 100644 --- a/infra/compose.test.yaml +++ b/infra/compose.test.yaml @@ -6,7 +6,7 @@ services: POSTGRES_USER: ${POSTGRES_TEST_USER:-drift} POSTGRES_PASSWORD: ${POSTGRES_TEST_PASSWORD:-drift_test_only} ports: - - "${POSTGRES_TEST_PORT:-55432}:5432" + - "127.0.0.1:${POSTGRES_TEST_PORT:-55432}:5432" healthcheck: test: [ @@ -21,7 +21,7 @@ services: image: redis:7-alpine command: ["redis-server", "--save", "", "--appendonly", "no"] ports: - - "${REDIS_TEST_PORT:-56379}:6379" + - "127.0.0.1:${REDIS_TEST_PORT:-56379}:6379" healthcheck: test: ["CMD", "redis-cli", "ping"] interval: 2s diff --git a/package.json b/package.json index f44e8a9..272e351 100644 --- a/package.json +++ b/package.json @@ -4,8 +4,9 @@ "private": true, "packageManager": "pnpm@9.15.0", "scripts": { - "test": "vitest run", + "test": "vitest run --no-file-parallelism", "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", "lint": "eslint . --ext .ts --max-warnings 0 && prettier --check .", "build": "corepack pnpm prisma generate && corepack pnpm --recursive run build", diff --git a/prisma/database-safety.ts b/prisma/database-safety.ts new file mode 100644 index 0000000..d128684 --- /dev/null +++ b/prisma/database-safety.ts @@ -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); +} diff --git a/prisma/migrations/0001_init/migration.sql b/prisma/migrations/0001_init/migration.sql index 591f767..648c012 100644 --- a/prisma/migrations/0001_init/migration.sql +++ b/prisma/migrations/0001_init/migration.sql @@ -5,7 +5,13 @@ CREATE TYPE "AccountStatus" AS ENUM ('ACTIVE', 'SUSPENDED', 'DELETED'); CREATE TYPE "AuthProvider" AS ENUM ('PHONE', 'APPLE', 'WECHAT'); -- 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 CREATE TYPE "ConversationStatus" AS ENUM ('ACTIVE', 'CLOSED'); @@ -45,9 +51,12 @@ CREATE TABLE "accounts" ( CREATE TABLE "anonymous_profiles" ( "id" UUID NOT NULL, "account_id" UUID NOT NULL, + "public_id" UUID NOT NULL, "nickname" VARCHAR(64) NOT NULL, "avatar_key" VARCHAR(255), + "avatar_color" VARCHAR(16) NOT NULL, "bio" VARCHAR(500), + "review_status" "ReviewStatus" NOT NULL DEFAULT 'REVIEWING', "created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "updated_at" TIMESTAMPTZ(3) NOT NULL, @@ -85,8 +94,11 @@ CREATE TABLE "bottles" ( "id" UUID NOT NULL, "author_id" UUID NOT NULL, "content_text" TEXT NOT NULL, - "status" "BottleStatus" NOT NULL DEFAULT 'DRAFT', - "picked_at" TIMESTAMPTZ(3), + "review_status" "ReviewStatus" NOT NULL DEFAULT 'DRAFT', + "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), "created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "updated_at" TIMESTAMPTZ(3) NOT NULL, @@ -101,7 +113,8 @@ CREATE TABLE "bottle_pick_leases" ( "picker_id" UUID NOT NULL, "lease_token_hash" VARCHAR(255) 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, CONSTRAINT "bottle_pick_leases_pkey" PRIMARY KEY ("id") @@ -136,7 +149,8 @@ CREATE TABLE "conversations" ( "id" UUID NOT NULL, "source_bottle_id" UUID NOT NULL, "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, "updated_at" TIMESTAMPTZ(3) NOT NULL, @@ -150,7 +164,9 @@ CREATE TABLE "conversation_members" ( "account_id" UUID NOT NULL, "joined_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "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") ); @@ -161,9 +177,12 @@ CREATE TABLE "messages" ( "conversation_id" UUID NOT NULL, "sender_id" UUID NOT NULL, "client_msg_id" VARCHAR(128) NOT NULL, - "seq" INTEGER NOT NULL, + "seq" BIGINT NOT NULL, "content_text" TEXT NOT NULL, "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, "updated_at" TIMESTAMPTZ(3) NOT NULL, @@ -190,6 +209,8 @@ CREATE TABLE "reports" ( "message_id" UUID, "reason" VARCHAR(100) NOT NULL, "details" TEXT, + "target_snapshot" JSONB NOT NULL, + "resolution" TEXT, "status" "ReportStatus" NOT NULL DEFAULT 'PENDING', "created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "updated_at" TIMESTAMPTZ(3) NOT NULL, @@ -200,11 +221,18 @@ CREATE TABLE "reports" ( -- CreateTable CREATE TABLE "moderation_tasks" ( "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, "status" "ModerationTaskStatus" NOT NULL DEFAULT 'PENDING', "decision" TEXT, "created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "reviewed_at" TIMESTAMPTZ(3), "updated_at" TIMESTAMPTZ(3) NOT NULL, CONSTRAINT "moderation_tasks_pkey" PRIMARY KEY ("id") @@ -250,7 +278,7 @@ CREATE TABLE "outbox_events" ( "payload" JSONB NOT NULL, "status" "OutboxStatus" NOT NULL DEFAULT 'PENDING', "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), "created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "updated_at" TIMESTAMPTZ(3) NOT NULL, @@ -279,6 +307,9 @@ CREATE UNIQUE INDEX "accounts_phone_hmac_key" ON "accounts"("phone_hmac"); -- CreateIndex 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 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"); -- 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 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 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 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"); -- 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 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; -- 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 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 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 + ); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index fce72d0..8f0707b 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -19,12 +19,27 @@ enum AuthProvider { WECHAT } -enum BottleStatus { +enum ReviewStatus { DRAFT + REVIEWING + REJECTED + MANUAL_REVIEW + APPROVED +} + +enum BottlePoolStatus { IN_POOL - PICKED - EXPIRED + LEASED + CONSUMED REMOVED + CLOSED +} + +enum BottlePickLeaseStatus { + ACTIVE + RETURNED + EXPIRED + CONSUMED } enum ConversationStatus { @@ -103,14 +118,17 @@ model Account { } model AnonymousProfile { - id String @id @default(uuid()) @db.Uuid - accountId String @unique @map("account_id") @db.Uuid - nickname String @db.VarChar(64) - avatarKey String? @map("avatar_key") @db.VarChar(255) - bio String? @db.VarChar(500) - 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) + id String @id @default(uuid()) @db.Uuid + accountId String @unique @map("account_id") @db.Uuid + publicId String @unique @default(uuid()) @map("public_id") @db.Uuid + nickname String @db.VarChar(64) + avatarKey String? @map("avatar_key") @db.VarChar(255) + avatarColor String @map("avatar_color") @db.VarChar(16) + bio String? @db.VarChar(500) + 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") } @@ -148,8 +166,11 @@ model Bottle { id String @id @default(uuid()) @db.Uuid authorId String @map("author_id") @db.Uuid contentText String @map("content_text") @db.Text - status BottleStatus @default(DRAFT) - pickedAt DateTime? @map("picked_at") @db.Timestamptz(3) + reviewStatus ReviewStatus @default(DRAFT) @map("review_status") + 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) createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3) @@ -159,21 +180,24 @@ model Bottle { conversation Conversation? reports Report[] - @@index([status, createdAt]) + @@index([poolStatus, createdAt]) + @@index([reviewStatus, createdAt]) @@index([authorId, createdAt]) @@map("bottles") } model BottlePickLease { - id String @id @default(uuid()) @db.Uuid - bottleId String @map("bottle_id") @db.Uuid - pickerId String @map("picker_id") @db.Uuid - leaseTokenHash String @unique @map("lease_token_hash") @db.VarChar(255) - expiresAt DateTime @map("expires_at") @db.Timestamptz(3) - consumedAt DateTime? @map("consumed_at") @db.Timestamptz(3) - 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) + /// Partial unique index bottle_pick_leases_one_active_per_bottle is managed in 0001_init SQL. + id String @id @default(uuid()) @db.Uuid + bottleId String @map("bottle_id") @db.Uuid + pickerId String @map("picker_id") @db.Uuid + leaseTokenHash String @unique @map("lease_token_hash") @db.VarChar(255) + expiresAt DateTime @map("expires_at") @db.Timestamptz(3) + status BottlePickLeaseStatus @default(ACTIVE) + endedAt DateTime? @map("ended_at") @db.Timestamptz(3) + 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([bottleId, expiresAt]) @@ -194,6 +218,7 @@ model BottlePickHistory { } model DailyUsage { + /// Nonnegative counters are enforced by CHECK constraints in 0001_init SQL. id String @id @default(uuid()) @db.Uuid accountId String @map("account_id") @db.Uuid usageDate DateTime @map("usage_date") @db.Date @@ -213,7 +238,8 @@ model Conversation { id String @id @default(uuid()) @db.Uuid sourceBottleId String @unique @map("source_bottle_id") @db.Uuid 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) updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3) sourceBottle Bottle @relation(fields: [sourceBottleId], references: [id], onDelete: Restrict) @@ -226,14 +252,16 @@ model Conversation { } model ConversationMember { - id String @id @default(uuid()) @db.Uuid - conversationId String @map("conversation_id") @db.Uuid - accountId String @map("account_id") @db.Uuid - joinedAt DateTime @default(now()) @map("joined_at") @db.Timestamptz(3) - leftAt DateTime? @map("left_at") @db.Timestamptz(3) - lastReadSeq Int @default(0) @map("last_read_seq") - conversation Conversation @relation(fields: [conversationId], references: [id], onDelete: Cascade) - account Account @relation(fields: [accountId], references: [id], onDelete: Cascade) + id String @id @default(uuid()) @db.Uuid + conversationId String @map("conversation_id") @db.Uuid + accountId String @map("account_id") @db.Uuid + joinedAt DateTime @default(now()) @map("joined_at") @db.Timestamptz(3) + leftAt DateTime? @map("left_at") @db.Timestamptz(3) + lastReadSeq BigInt @default(0) @map("last_read_seq") + peerAliasSnapshot String @map("peer_alias_snapshot") @db.VarChar(64) + 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]) @@index([accountId, joinedAt]) @@ -245,9 +273,12 @@ model Message { conversationId String @map("conversation_id") @db.Uuid senderId String @map("sender_id") @db.Uuid clientMsgId String @map("client_msg_id") @db.VarChar(128) - seq Int + seq BigInt contentText String @map("content_text") @db.Text 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) updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3) conversation Conversation @relation(fields: [conversationId], references: [id], onDelete: Cascade) @@ -274,6 +305,7 @@ model Block { } model Report { + /// Exactly one nullable target foreign key must be set; enforced in 0001_init SQL. id String @id @default(uuid()) @db.Uuid reporterId String @map("reporter_id") @db.Uuid reportedAccountId String? @map("reported_account_id") @db.Uuid @@ -282,6 +314,8 @@ model Report { messageId String? @map("message_id") @db.Uuid reason String @db.VarChar(100) details String? @db.Text + targetSnapshot Json @map("target_snapshot") + resolution String? @db.Text status ReportStatus @default(PENDING) createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3) @@ -299,17 +333,25 @@ model Report { model ModerationTask { 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 status ModerationTaskStatus @default(PENDING) decision String? @db.Text 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) - 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) sanctions Sanction[] @@index([status, createdAt]) + @@index([targetType, targetId]) @@index([assignedToId, status]) @@map("moderation_tasks") } @@ -357,12 +399,12 @@ model OutboxEvent { payload Json status OutboxStatus @default(PENDING) 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) createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3) - @@index([status, availableAt]) + @@index([status, nextRetryAt]) @@index([aggregateType, aggregateId]) @@map("outbox_events") } diff --git a/prisma/seed.ts b/prisma/seed.ts index 90770ae..679a619 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -1,20 +1,46 @@ import { createHash } from "node:crypto"; 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"); async function seed(): Promise { const accounts = await Promise.all( [ - { key: "demo-a", nickname: "海风" }, - { key: "demo-b", nickname: "星河" }, - ].map(({ key, nickname }) => + { + key: "demo-a", + nickname: "海风", + publicId: "00000000-0000-4000-8000-000000000001", + }, + { + key: "demo-b", + nickname: "星河", + publicId: "00000000-0000-4000-8000-000000000002", + }, + ].map(({ key, nickname, publicId }) => prisma.account.upsert({ where: { phoneHmac: id(`drift-bottle:${key}:phone-hmac`) }, update: { anonymousProfile: { - upsert: { create: { nickname }, update: { nickname } }, + upsert: { + create: { + publicId, + nickname, + avatarColor: "#66CCFF", + reviewStatus: "APPROVED", + }, + update: { + nickname, + avatarColor: "#66CCFF", + reviewStatus: "APPROVED", + }, + }, }, }, create: { @@ -23,7 +49,14 @@ async function seed(): Promise { "hex", ), 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 { const author = accounts[0]; 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({ where: { id: "00000000-0000-4000-8000-000000000101" }, - update: { - authorId: author.id, - contentText: "愿你今天遇见温柔。", - status: "IN_POOL", - }, - create: { - id: "00000000-0000-4000-8000-000000000101", - authorId: author.id, - contentText: "愿你今天遇见温柔。", - status: "IN_POOL", - }, + update: bottleData, + create: { id: "00000000-0000-4000-8000-000000000101", ...bottleData }, }); console.info( @@ -54,10 +84,8 @@ async function seed(): Promise { seed() .catch((error: unknown) => { - console.error( - "Seed failed", - error instanceof Error ? error.message : "unknown error", - ); + void error; + console.error("Seed failed."); process.exitCode = 1; }) .finally(async () => prisma.$disconnect()); diff --git a/tests/integration/database-url.spec.ts b/tests/integration/database-url.spec.ts new file mode 100644 index 0000000..650c275 --- /dev/null +++ b/tests/integration/database-url.spec.ts @@ -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(); + }); +}); diff --git a/tests/integration/database.spec.ts b/tests/integration/database.spec.ts index c0bef07..d6d38b3 100644 --- a/tests/integration/database.spec.ts +++ b/tests/integration/database.spec.ts @@ -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) { +async function expectConstraintViolation(operation: Promise) { await expect(operation).rejects.toMatchObject({ - code: "P2002", - } satisfies Partial); + name: "PrismaClientUnknownRequestError", + }); +} + +async function expectUniqueViolation(operation: Promise) { + 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 }, }), ); }); diff --git a/tests/integration/seed.spec.ts b/tests/integration/seed.spec.ts new file mode 100644 index 0000000..47919e3 --- /dev/null +++ b/tests/integration/seed.spec.ts @@ -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]); + }); +});