fix: 完善权威数据约束与测试隔离
This commit is contained in:
@@ -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
@@ -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",
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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
@@ -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
@@ -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());
|
||||||
|
|||||||
@@ -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 { 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: "二",
|
|
||||||
},
|
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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