feat: 实现投瓶和异步内容审核
This commit is contained in:
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user