feat: 实现投瓶和异步内容审核

This commit is contained in:
root
2026-09-15 01:28:11 +08:00
parent b480d2c91f
commit f455552e92
25 changed files with 1204 additions and 18 deletions
+285
View File
@@ -0,0 +1,285 @@
/* eslint-disable @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-argument */
import "reflect-metadata";
import { type INestApplication } from "@nestjs/common";
import { Test } from "@nestjs/testing";
import { PrismaClient } from "@prisma/client";
import { randomUUID } from "node:crypto";
import request from "supertest";
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
import { assertSafeTestDatabaseUrl } from "../../../../prisma/database-safety.js";
import { AppModule } from "../app.module.js";
import { TokenService } from "../auth/token.service.js";
import { configureApp } from "../main.js";
import { utc8UsageDate } from "./usage-date.js";
process.env.NODE_ENV = "test";
process.env.WEB_ORIGIN = "http://localhost:3000";
process.env.PHONE_ENCRYPTION_KEY =
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
process.env.PHONE_HMAC_KEY = "test-phone-hmac-key-with-at-least-32-bytes";
process.env.VERIFICATION_CODE_HMAC_KEY =
"test-code-hmac-key-with-at-least-32-bytes";
process.env.JWT_SECRET = "test-jwt-secret-with-at-least-thirty-two-bytes";
process.env.REFRESH_TOKEN_HMAC_KEY =
"test-refresh-hmac-key-with-at-least-32-bytes";
const prisma = new PrismaClient();
describe("bottles with real PostgreSQL", () => {
let app: INestApplication;
let accountId: string;
let authorization: string;
beforeAll(async () => {
assertSafeTestDatabaseUrl(process.env.DATABASE_URL ?? "");
await prisma.$connect();
const module = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = module.createNestApplication();
configureApp(app);
await app.init();
});
beforeEach(async () => {
await prisma.outboxEvent.deleteMany();
await prisma.$executeRawUnsafe(`TRUNCATE TABLE "accounts" CASCADE`);
({ accountId, authorization } = await actor("owner-device"));
});
afterAll(async () => {
await app?.close();
await prisma.$disconnect();
});
async function actor(deviceId: string) {
const account = await prisma.account.create({
data: { phoneCiphertext: Buffer.from("cipher"), phoneHmac: randomUUID() },
});
const session = await prisma.session.create({
data: {
accountId: account.id,
refreshTokenHash: randomUUID(),
deviceId,
expiresAt: new Date(Date.now() + 60_000),
tokenFamily: randomUUID(),
},
});
const token = app.get(TokenService).issueAccess({
sub: account.id,
session_id: session.id,
device_id: deviceId,
scopes: ["user"],
token_version: 0,
});
return { accountId: account.id, authorization: `Bearer ${token}` };
}
const create = (key: string, contentText = "hello sea") =>
request(app.getHttpServer())
.post("/api/v1/bottles")
.set("Authorization", authorization)
.set("Idempotency-Key", key)
.send({ contentText });
it("creates reviewing/closed bottle, task, and content-free outbox atomically", async () => {
const response = await create(randomUUID(), " hello sea ").expect(201);
expect(response.body.data).toMatchObject({
contentText: "hello sea",
reviewStatus: "REVIEWING",
poolStatus: "CLOSED",
});
const bottle = await prisma.bottle.findUniqueOrThrow({
where: { id: response.body.data.id },
});
const [task, event, usage] = await Promise.all([
prisma.moderationTask.findFirstOrThrow({
where: { targetId: bottle.id },
}),
prisma.outboxEvent.findFirstOrThrow({
where: { aggregateId: bottle.id },
}),
prisma.dailyUsage.findFirstOrThrow({ where: { accountId } }),
]);
expect(task.payloadHash).toMatch(/^[a-f0-9]{64}$/);
expect(event.payload).toEqual({ bottleId: bottle.id, taskId: task.id });
expect(JSON.stringify(event.payload)).not.toContain("hello sea");
expect(usage.bottlesCreated).toBe(1);
});
it("deduplicates retry without incrementing quota or creating events", async () => {
const key = randomUUID();
const first = await create(key).expect(201);
const retry = await create(key).expect(201);
expect(retry.body.data.id).toBe(first.body.data.id);
expect(await prisma.bottle.count()).toBe(1);
expect(await prisma.moderationTask.count()).toBe(1);
expect(await prisma.outboxEvent.count()).toBe(1);
expect((await prisma.dailyUsage.findFirstOrThrow()).bottlesCreated).toBe(1);
});
it("deduplicates concurrent retries without incrementing quota", async () => {
const key = randomUUID();
const results = await Promise.all(
Array.from({ length: 8 }, () => create(key, "same content")),
);
expect(results.every((result) => result.status === 201)).toBe(true);
expect(
new Set(results.map((result) => (result.body.data as { id: string }).id)),
).toHaveLength(1);
expect(await prisma.bottle.count()).toBe(1);
expect(await prisma.moderationTask.count()).toBe(1);
expect(await prisma.outboxEvent.count()).toBe(1);
expect((await prisma.dailyUsage.findFirstOrThrow()).bottlesCreated).toBe(1);
});
it("atomically permits exactly ten of twelve concurrent requests", async () => {
const results = await Promise.all(
Array.from({ length: 12 }, (_, index) =>
create(randomUUID(), `b${index}`),
),
);
expect(results.filter((x) => x.status === 201)).toHaveLength(10);
const denied = results.filter((x) => x.status === 429);
expect(denied).toHaveLength(2);
expect(denied.every((x) => x.body.code === "BOTTLE_DAILY_LIMIT")).toBe(
true,
);
expect(await prisma.bottle.count()).toBe(10);
expect(await prisma.outboxEvent.count()).toBe(10);
expect((await prisma.dailyUsage.findFirstOrThrow()).bottlesCreated).toBe(
10,
);
});
it("validates trimmed content and idempotency key", async () => {
await create(randomUUID(), " ").expect(400);
await create(randomUUID(), "x".repeat(1001)).expect(400);
await request(app.getHttpServer())
.post("/api/v1/bottles")
.set("Authorization", authorization)
.send({ contentText: "hello" })
.expect(400);
});
it("allows only owner reads and cursor-paginates own bottles", async () => {
const ids: string[] = [];
for (let n = 0; n < 3; n += 1)
ids.push(
(await create(randomUUID(), `message${n}`).expect(201)).body.data.id,
);
await request(app.getHttpServer())
.get(`/api/v1/bottles/${ids[0]}`)
.set("Authorization", authorization)
.expect(200);
const stranger = await actor("stranger-device");
await request(app.getHttpServer())
.get(`/api/v1/bottles/${ids[0]}`)
.set("Authorization", stranger.authorization)
.expect(404);
const page1 = await request(app.getHttpServer())
.get("/api/v1/me/bottles?limit=2")
.set("Authorization", authorization)
.expect(200);
expect(page1.body.data.items).toHaveLength(2);
expect(page1.body.data.nextCursor).toEqual(expect.any(String));
const page2 = await request(app.getHttpServer())
.get(`/api/v1/me/bottles?limit=2&cursor=${page1.body.data.nextCursor}`)
.set("Authorization", authorization)
.expect(200);
expect(page2.body.data.items).toHaveLength(1);
expect(page2.body.data.nextCursor).toBeNull();
});
it("returns 400 for malformed, missing, too-long, and foreign cursors", async () => {
await request(app.getHttpServer())
.get("/api/v1/me/bottles?cursor=not-a-uuid")
.set("Authorization", authorization)
.expect(400);
await request(app.getHttpServer())
.get(`/api/v1/me/bottles?cursor=${randomUUID()}`)
.set("Authorization", authorization)
.expect(400);
const stranger = await actor("cursor-stranger");
const foreignId = (
await request(app.getHttpServer())
.post("/api/v1/bottles")
.set("Authorization", stranger.authorization)
.set("Idempotency-Key", randomUUID())
.send({ contentText: "foreign" })
.expect(201)
).body.data.id;
await request(app.getHttpServer())
.get(`/api/v1/me/bottles?cursor=${foreignId}`)
.set("Authorization", authorization)
.expect(400);
});
it("rejects blank and overlong idempotency keys", async () => {
await create(" ").expect(400);
await create("x".repeat(129)).expect(400);
});
it.each(["SUSPENSION", "BAN"] as const)(
"blocks active %s sanction",
async (type) => {
await prisma.sanction.create({
data: {
accountId,
type,
reason: "policy",
expiresAt: new Date(Date.now() + 60_000),
},
});
const denied = await create(randomUUID()).expect(403);
expect(denied.body.code).toBe("ACCOUNT_SANCTIONED");
expect(await prisma.bottle.count()).toBe(0);
},
);
it("blocks non-active accounts", async () => {
await prisma.account.update({
where: { id: accountId },
data: { status: "SUSPENDED" },
});
await create(randomUUID()).expect(401);
expect(await prisma.bottle.count()).toBe(0);
});
it("blocks sanctions that have started but ignores future sanctions", async () => {
await prisma.sanction.create({
data: {
accountId,
type: "BAN",
reason: "future",
startsAt: new Date(Date.now() + 60_000),
},
});
await create(randomUUID()).expect(201);
});
it("ignores expired and revoked sanctions", async () => {
await prisma.sanction.createMany({
data: [
{
accountId,
type: "BAN",
reason: "expired",
expiresAt: new Date(Date.now() - 1),
},
{
accountId,
type: "SUSPENSION",
reason: "revoked",
revokedAt: new Date(),
},
],
});
await create(randomUUID()).expect(201);
});
it("computes usage day across UTC+8 midnight", () => {
expect(utc8UsageDate(new Date("2026-09-15T15:59:59.999Z"))).toBe(
"2026-09-15",
);
expect(utc8UsageDate(new Date("2026-09-15T16:00:00.000Z"))).toBe(
"2026-09-16",
);
});
});