fix: 完善资料审核与 Worker 状态一致性
This commit is contained in:
@@ -133,6 +133,230 @@ describe("moderation worker with real PostgreSQL", () => {
|
||||
).toBe("PUBLISHED");
|
||||
});
|
||||
|
||||
it("publishes a replay when the matching bottle task is already completed", async () => {
|
||||
const { bottle, event, task } = await fixture("ordinary");
|
||||
await prisma.moderationTask.update({
|
||||
where: { id: task.id },
|
||||
data: {
|
||||
status: "COMPLETED",
|
||||
decision: "APPROVED",
|
||||
result: { decision: "APPROVED" },
|
||||
reviewedAt: new Date(),
|
||||
},
|
||||
});
|
||||
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" });
|
||||
expect(
|
||||
await prisma.bottle.findUniqueOrThrow({ where: { id: bottle.id } }),
|
||||
).toMatchObject({ reviewStatus: "REVIEWING", poolStatus: "CLOSED" });
|
||||
});
|
||||
|
||||
it("fails the event and leaves the task open when bottle state drift prevents the update", async () => {
|
||||
const { bottle, event, task } = await fixture("ordinary");
|
||||
await prisma.bottle.update({
|
||||
where: { id: bottle.id },
|
||||
data: { reviewStatus: "APPROVED", poolStatus: "IN_POOL" },
|
||||
});
|
||||
expect(await new ModerationWorker(prisma).runOnce()).toBe(true);
|
||||
expect(
|
||||
await prisma.outboxEvent.findUniqueOrThrow({ where: { id: event.id } }),
|
||||
).toMatchObject({ status: "FAILED", attempts: 1 });
|
||||
expect(
|
||||
await prisma.moderationTask.findUniqueOrThrow({ where: { id: task.id } }),
|
||||
).toMatchObject({ status: "PENDING", decision: null });
|
||||
});
|
||||
|
||||
it("reaps a stale PROCESSING event already at max attempts without handling it", async () => {
|
||||
const { event } = await fixture("ordinary");
|
||||
await prisma.outboxEvent.update({
|
||||
where: { id: event.id },
|
||||
data: {
|
||||
status: "PROCESSING",
|
||||
attempts: 5,
|
||||
lockedAt: new Date(Date.now() - 60_000),
|
||||
},
|
||||
});
|
||||
const decide = vi.fn(decideModeration);
|
||||
expect(await new ModerationWorker(prisma, decide, 1000).runOnce()).toBe(
|
||||
false,
|
||||
);
|
||||
expect(decide).not.toHaveBeenCalled();
|
||||
expect(
|
||||
await prisma.outboxEvent.findUniqueOrThrow({ where: { id: event.id } }),
|
||||
).toMatchObject({ status: "FAILED", attempts: 5, lockedAt: null });
|
||||
});
|
||||
|
||||
it.each([
|
||||
["ordinary", "APPROVED"],
|
||||
["reject-word", "REJECTED"],
|
||||
["review-word", "MANUAL_REVIEW"],
|
||||
] as const)(
|
||||
"moderates normalized profile content with rule %#",
|
||||
async (nickname, status) => {
|
||||
const { profile, event, task } = await profileFixture(
|
||||
nickname,
|
||||
"profile bio",
|
||||
);
|
||||
expect(await new ModerationWorker(prisma).runOnce()).toBe(true);
|
||||
expect(
|
||||
(
|
||||
await prisma.anonymousProfile.findUniqueOrThrow({
|
||||
where: { id: profile.id },
|
||||
})
|
||||
).reviewStatus,
|
||||
).toBe(status);
|
||||
expect(
|
||||
await prisma.moderationTask.findUniqueOrThrow({
|
||||
where: { id: task.id },
|
||||
}),
|
||||
).toMatchObject({ status: "COMPLETED", decision: status });
|
||||
expect(
|
||||
(
|
||||
await prisma.outboxEvent.findUniqueOrThrow({
|
||||
where: { id: event.id },
|
||||
})
|
||||
).status,
|
||||
).toBe("PUBLISHED");
|
||||
},
|
||||
);
|
||||
|
||||
it("publishes a profile event that becomes stale while moderation is running", async () => {
|
||||
const { createHash } = await import("node:crypto");
|
||||
const { profile, event, task } = await profileFixture("ordinary", "first");
|
||||
const currentText = JSON.stringify(["reject-word", "#abcdef", "second"]);
|
||||
const decide = vi.fn(async (text: string) => {
|
||||
await prisma.$transaction([
|
||||
prisma.anonymousProfile.update({
|
||||
where: { id: profile.id },
|
||||
data: {
|
||||
nickname: "reject-word",
|
||||
bio: "second",
|
||||
version: 2,
|
||||
reviewStatus: "REVIEWING",
|
||||
},
|
||||
}),
|
||||
prisma.moderationTask.update({
|
||||
where: { id: task.id },
|
||||
data: {
|
||||
payloadHash: createHash("sha256").update(currentText).digest("hex"),
|
||||
status: "PENDING",
|
||||
},
|
||||
}),
|
||||
]);
|
||||
return decideModeration(text);
|
||||
});
|
||||
|
||||
expect(await new ModerationWorker(prisma, decide).runOnce()).toBe(true);
|
||||
expect(decide).toHaveBeenCalledTimes(1);
|
||||
expect(
|
||||
await prisma.outboxEvent.findUniqueOrThrow({ where: { id: event.id } }),
|
||||
).toMatchObject({ status: "PUBLISHED" });
|
||||
expect(
|
||||
await prisma.anonymousProfile.findUniqueOrThrow({
|
||||
where: { id: profile.id },
|
||||
}),
|
||||
).toMatchObject({ version: 2, reviewStatus: "REVIEWING" });
|
||||
expect(
|
||||
await prisma.moderationTask.findUniqueOrThrow({ where: { id: task.id } }),
|
||||
).toMatchObject({ status: "PENDING", decision: null });
|
||||
});
|
||||
|
||||
it("publishes a replay when the matching profile task is already completed", async () => {
|
||||
const { profile, event, task } = await profileFixture("ordinary", "first");
|
||||
await prisma.moderationTask.update({
|
||||
where: { id: task.id },
|
||||
data: {
|
||||
status: "COMPLETED",
|
||||
decision: "APPROVED",
|
||||
result: { decision: "APPROVED" },
|
||||
reviewedAt: new Date(),
|
||||
},
|
||||
});
|
||||
const decide = vi.fn(decideModeration);
|
||||
|
||||
expect(await new ModerationWorker(prisma, decide).runOnce()).toBe(true);
|
||||
expect(
|
||||
await prisma.outboxEvent.findUniqueOrThrow({ where: { id: event.id } }),
|
||||
).toMatchObject({ status: "PUBLISHED" });
|
||||
expect(
|
||||
await prisma.anonymousProfile.findUniqueOrThrow({
|
||||
where: { id: profile.id },
|
||||
}),
|
||||
).toMatchObject({ reviewStatus: "REVIEWING" });
|
||||
expect(
|
||||
await prisma.moderationTask.findUniqueOrThrow({ where: { id: task.id } }),
|
||||
).toMatchObject({ status: "COMPLETED", decision: "APPROVED" });
|
||||
});
|
||||
|
||||
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");
|
||||
const currentText = JSON.stringify(["reject-word", "#abcdef", "second"]);
|
||||
await prisma.$transaction([
|
||||
prisma.anonymousProfile.update({
|
||||
where: { id: profile.id },
|
||||
data: {
|
||||
nickname: "reject-word",
|
||||
bio: "second",
|
||||
version: 2,
|
||||
reviewStatus: "REVIEWING",
|
||||
},
|
||||
}),
|
||||
prisma.moderationTask.update({
|
||||
where: { id: task.id },
|
||||
data: {
|
||||
payloadHash: createHash("sha256").update(currentText).digest("hex"),
|
||||
},
|
||||
}),
|
||||
prisma.outboxEvent.create({
|
||||
data: {
|
||||
aggregateType: "PROFILE",
|
||||
aggregateId: profile.id,
|
||||
eventType: "PROFILE_MODERATION_REQUESTED",
|
||||
dedupeKey: `profile-moderation:${profile.id}:2`,
|
||||
payload: {
|
||||
profileId: profile.id,
|
||||
accountId: profile.accountId,
|
||||
taskId: task.id,
|
||||
},
|
||||
},
|
||||
}),
|
||||
]);
|
||||
const worker = new ModerationWorker(prisma);
|
||||
expect(await worker.runOnce()).toBe(true);
|
||||
expect(
|
||||
(await prisma.outboxEvent.findUniqueOrThrow({ where: { id: event.id } }))
|
||||
.status,
|
||||
).toBe("PUBLISHED");
|
||||
expect(
|
||||
(
|
||||
await prisma.anonymousProfile.findUniqueOrThrow({
|
||||
where: { id: profile.id },
|
||||
})
|
||||
).reviewStatus,
|
||||
).toBe("REVIEWING");
|
||||
expect(
|
||||
(
|
||||
await prisma.moderationTask.findUniqueOrThrow({
|
||||
where: { id: task.id },
|
||||
})
|
||||
).status,
|
||||
).toBe("PENDING");
|
||||
expect(await worker.runOnce()).toBe(true);
|
||||
expect(
|
||||
(
|
||||
await prisma.anonymousProfile.findUniqueOrThrow({
|
||||
where: { id: profile.id },
|
||||
})
|
||||
).reviewStatus,
|
||||
).toBe("REJECTED");
|
||||
});
|
||||
|
||||
async function fixture(contentText: string) {
|
||||
const account = await prisma.account.create({
|
||||
data: { phoneCiphertext: Buffer.from("cipher"), phoneHmac: randomUUID() },
|
||||
@@ -166,4 +390,44 @@ describe("moderation worker with real PostgreSQL", () => {
|
||||
});
|
||||
return { bottle, task, event };
|
||||
}
|
||||
|
||||
async function profileFixture(nickname: string, bio: string) {
|
||||
const { createHash } = await import("node:crypto");
|
||||
const account = await prisma.account.create({
|
||||
data: { phoneCiphertext: Buffer.from("cipher"), phoneHmac: randomUUID() },
|
||||
});
|
||||
const profile = await prisma.anonymousProfile.create({
|
||||
data: {
|
||||
accountId: account.id,
|
||||
nickname,
|
||||
avatarColor: "#abcdef",
|
||||
bio,
|
||||
version: 1,
|
||||
},
|
||||
});
|
||||
const text = JSON.stringify([nickname, "#abcdef", bio]);
|
||||
const task = await prisma.moderationTask.create({
|
||||
data: {
|
||||
targetType: "PROFILE",
|
||||
targetId: profile.id,
|
||||
provider: "SIMULATED",
|
||||
payloadHash: createHash("sha256").update(text).digest("hex"),
|
||||
riskLabels: [],
|
||||
},
|
||||
});
|
||||
const event = await prisma.outboxEvent.create({
|
||||
data: {
|
||||
aggregateType: "PROFILE",
|
||||
aggregateId: profile.id,
|
||||
eventType: "PROFILE_MODERATION_REQUESTED",
|
||||
dedupeKey: `profile-moderation:${profile.id}:1`,
|
||||
payload: {
|
||||
profileId: profile.id,
|
||||
accountId: account.id,
|
||||
taskId: task.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
return { profile, task, event };
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user