fix: 加固 Outbox 租约与 Worker 生命周期
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import { afterAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { assertSafeTestDatabaseUrl } from "../../../prisma/database-safety.js";
|
||||
import { ModerationWorker, decideModeration } from "./moderation-worker.js";
|
||||
@@ -133,8 +133,104 @@ describe("moderation worker with real PostgreSQL", () => {
|
||||
).toBe("PUBLISHED");
|
||||
});
|
||||
|
||||
it("publishes a replay when the matching bottle task is already completed", async () => {
|
||||
it("does not let an expired lease report failure after a new owner publishes", async () => {
|
||||
const { bottle, event } = await fixture("ordinary");
|
||||
let rejectFirst!: (reason: Error) => void;
|
||||
const firstDecision = new Promise<ReturnType<typeof decideModeration>>(
|
||||
(_resolve, reject) => {
|
||||
rejectFirst = reject;
|
||||
},
|
||||
);
|
||||
const firstRun = new ModerationWorker(
|
||||
prisma,
|
||||
() => firstDecision,
|
||||
1000,
|
||||
).runOnce();
|
||||
await waitForProcessing(event.id);
|
||||
await prisma.outboxEvent.update({
|
||||
where: { id: event.id },
|
||||
data: { lockedAt: new Date(Date.now() - 60_000) },
|
||||
});
|
||||
await new ModerationWorker(prisma, undefined, 1000).runOnce();
|
||||
rejectFirst(new Error("secret-body-provider-error"));
|
||||
await firstRun;
|
||||
expect(
|
||||
await prisma.outboxEvent.findUniqueOrThrow({ where: { id: event.id } }),
|
||||
).toMatchObject({ status: "PUBLISHED" });
|
||||
expect(
|
||||
await prisma.bottle.findUniqueOrThrow({ where: { id: bottle.id } }),
|
||||
).toMatchObject({ reviewStatus: "APPROVED", poolStatus: "IN_POOL" });
|
||||
});
|
||||
|
||||
it("does not let an expired lease publish or overwrite a new owner's decision", async () => {
|
||||
const { bottle, event, task } = await fixture("ordinary");
|
||||
let resolveFirst!: (decision: ReturnType<typeof decideModeration>) => void;
|
||||
const firstDecision = new Promise<ReturnType<typeof decideModeration>>(
|
||||
(resolve) => {
|
||||
resolveFirst = resolve;
|
||||
},
|
||||
);
|
||||
const firstRun = new ModerationWorker(
|
||||
prisma,
|
||||
() => firstDecision,
|
||||
1000,
|
||||
).runOnce();
|
||||
await waitForProcessing(event.id);
|
||||
await prisma.outboxEvent.update({
|
||||
where: { id: event.id },
|
||||
data: { lockedAt: new Date(Date.now() - 60_000) },
|
||||
});
|
||||
await new ModerationWorker(
|
||||
prisma,
|
||||
() => ({
|
||||
reviewStatus: "APPROVED",
|
||||
poolStatus: "IN_POOL",
|
||||
labels: ["NEW_OWNER"],
|
||||
}),
|
||||
1000,
|
||||
).runOnce();
|
||||
resolveFirst({
|
||||
reviewStatus: "REJECTED",
|
||||
poolStatus: "CLOSED",
|
||||
labels: ["OLD_OWNER"],
|
||||
});
|
||||
await firstRun;
|
||||
expect(
|
||||
await prisma.bottle.findUniqueOrThrow({ where: { id: bottle.id } }),
|
||||
).toMatchObject({ reviewStatus: "APPROVED", poolStatus: "IN_POOL" });
|
||||
expect(
|
||||
await prisma.moderationTask.findUniqueOrThrow({ where: { id: task.id } }),
|
||||
).toMatchObject({ decision: "APPROVED", riskLabels: ["NEW_OWNER"] });
|
||||
});
|
||||
|
||||
it("publishes a replay when the completed bottle task matches current state", async () => {
|
||||
const { bottle, event, task } = await fixture("ordinary");
|
||||
await prisma.$transaction([
|
||||
prisma.moderationTask.update({
|
||||
where: { id: task.id },
|
||||
data: {
|
||||
status: "COMPLETED",
|
||||
decision: "APPROVED",
|
||||
result: { decision: "APPROVED" },
|
||||
reviewedAt: new Date(),
|
||||
},
|
||||
}),
|
||||
prisma.bottle.update({
|
||||
where: { id: bottle.id },
|
||||
data: { reviewStatus: "APPROVED", poolStatus: "IN_POOL" },
|
||||
}),
|
||||
]);
|
||||
const decide = vi.fn(decideModeration);
|
||||
|
||||
expect(await new ModerationWorker(prisma, decide).runOnce()).toBe(true);
|
||||
expect(decide).not.toHaveBeenCalled();
|
||||
expect(
|
||||
await prisma.outboxEvent.findUniqueOrThrow({ where: { id: event.id } }),
|
||||
).toMatchObject({ status: "PUBLISHED" });
|
||||
});
|
||||
|
||||
it("fails a completed bottle event whose result drifted from current state", async () => {
|
||||
const { event, task } = await fixture("secret bottle text");
|
||||
await prisma.moderationTask.update({
|
||||
where: { id: task.id },
|
||||
data: {
|
||||
@@ -144,16 +240,14 @@ describe("moderation worker with real PostgreSQL", () => {
|
||||
reviewedAt: new Date(),
|
||||
},
|
||||
});
|
||||
const decide = vi.fn(decideModeration);
|
||||
|
||||
expect(await new ModerationWorker(prisma, decide).runOnce()).toBe(true);
|
||||
expect(decide).not.toHaveBeenCalled();
|
||||
const logs: unknown[] = [];
|
||||
await new ModerationWorker(prisma, undefined, undefined, {
|
||||
error: (entry) => logs.push(entry),
|
||||
}).runOnce();
|
||||
expect(
|
||||
await prisma.outboxEvent.findUniqueOrThrow({ where: { id: event.id } }),
|
||||
).toMatchObject({ status: "PUBLISHED" });
|
||||
expect(
|
||||
await prisma.bottle.findUniqueOrThrow({ where: { id: bottle.id } }),
|
||||
).toMatchObject({ reviewStatus: "REVIEWING", poolStatus: "CLOSED" });
|
||||
).toMatchObject({ status: "FAILED" });
|
||||
expect(JSON.stringify(logs)).not.toContain("secret bottle text");
|
||||
});
|
||||
|
||||
it("fails the event and leaves the task open when bottle state drift prevents the update", async () => {
|
||||
@@ -266,7 +360,7 @@ describe("moderation worker with real PostgreSQL", () => {
|
||||
).toMatchObject({ status: "PENDING", decision: null });
|
||||
});
|
||||
|
||||
it("publishes a replay when the matching profile task is already completed", async () => {
|
||||
it("fails a replay when the completed profile task drifted from current state", async () => {
|
||||
const { profile, event, task } = await profileFixture("ordinary", "first");
|
||||
await prisma.moderationTask.update({
|
||||
where: { id: task.id },
|
||||
@@ -282,7 +376,7 @@ describe("moderation worker with real PostgreSQL", () => {
|
||||
expect(await new ModerationWorker(prisma, decide).runOnce()).toBe(true);
|
||||
expect(
|
||||
await prisma.outboxEvent.findUniqueOrThrow({ where: { id: event.id } }),
|
||||
).toMatchObject({ status: "PUBLISHED" });
|
||||
).toMatchObject({ status: "FAILED" });
|
||||
expect(
|
||||
await prisma.anonymousProfile.findUniqueOrThrow({
|
||||
where: { id: profile.id },
|
||||
@@ -293,6 +387,29 @@ describe("moderation worker with real PostgreSQL", () => {
|
||||
).toMatchObject({ status: "COMPLETED", decision: "APPROVED" });
|
||||
});
|
||||
|
||||
it("logs a safe error category without payload or exception message", async () => {
|
||||
const { event } = await fixture("secret正文");
|
||||
const entries: unknown[] = [];
|
||||
await new ModerationWorker(
|
||||
prisma,
|
||||
() => {
|
||||
throw new TypeError("provider leaked secret正文");
|
||||
},
|
||||
undefined,
|
||||
{ error: (entry) => entries.push(entry) },
|
||||
).runOnce();
|
||||
expect(entries).toEqual([
|
||||
{
|
||||
eventId: event.id,
|
||||
eventType: "BOTTLE_MODERATION_REQUESTED",
|
||||
attempt: 1,
|
||||
errorClass: "TypeError",
|
||||
},
|
||||
]);
|
||||
expect(JSON.stringify(entries)).not.toContain("secret正文");
|
||||
expect(JSON.stringify(entries)).not.toContain("provider leaked");
|
||||
});
|
||||
|
||||
it("publishes an old profile event without letting it overwrite a newer version", async () => {
|
||||
const { createHash } = await import("node:crypto");
|
||||
const { profile, event, task } = await profileFixture("ordinary", "first");
|
||||
@@ -375,7 +492,7 @@ describe("moderation worker with real PostgreSQL", () => {
|
||||
targetType: "BOTTLE",
|
||||
targetId: bottle.id,
|
||||
provider: "SIMULATED",
|
||||
payloadHash: "a".repeat(64),
|
||||
payloadHash: createHash("sha256").update(contentText).digest("hex"),
|
||||
riskLabels: [],
|
||||
},
|
||||
});
|
||||
@@ -430,4 +547,15 @@ describe("moderation worker with real PostgreSQL", () => {
|
||||
});
|
||||
return { profile, task, event };
|
||||
}
|
||||
|
||||
async function waitForProcessing(id: string) {
|
||||
for (let attempt = 0; attempt < 100; attempt += 1) {
|
||||
const event = await prisma.outboxEvent.findUniqueOrThrow({
|
||||
where: { id },
|
||||
});
|
||||
if (event.status === "PROCESSING") return;
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
}
|
||||
throw new Error("worker did not claim event");
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user