fix(news): persist publication requests and retry cache delivery
CI / check (push) Successful in 3m12s
CI / deploy (push) Successful in 1m13s
CI / publish-container (push) Successful in 42s

This commit is contained in:
Simo committed 2026-09-13 18:33:45 +02:00
1 parent c977fe95ba
commit 52f6d1491f
40 files changed
+733 -134

No files matched your search

+105
View File
@@ -2,6 +2,11 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
const state = vi.hoisted(() => ({
rows: [] as unknown[][],
operations: new Map<
string,
{ id: string; requestHash: string; resultJson: string | null }
>(),
effects: [] as unknown[],
insert: vi.fn(),
update: vi.fn(),
invalidate: vi.fn(),
@@ -32,7 +37,32 @@ vi.mock("next/navigation", () => ({
},
}));
vi.mock("@/lib/db", async () => {
const { MySqlDialect } = await import("drizzle-orm/mysql-core");
const dialect = new MySqlDialect();
const database = {
execute: async (statement: import("drizzle-orm").SQL) => {
const { sql, params } = dialect.sqlToQuery(statement);
if (sql.startsWith("INSERT INTO cms_operations")) {
const [id, actor, kind, key, hash] = params;
const identity = `${actor}:${kind}:${key}`;
if (!state.operations.has(identity))
state.operations.set(identity, {
id: String(id),
requestHash: String(hash),
resultJson: null,
});
} else if (sql.startsWith("SELECT id,request_hash")) {
return [[state.operations.get(params.join(":"))]];
} else if (sql.startsWith("UPDATE cms_operations")) {
const row = [...state.operations.values()].find(
(row) => row.id === params[1],
);
if (row) row.resultJson = String(params[0]);
} else if (sql.startsWith("INSERT INTO cms_outbox"))
state.effects.push(params);
else throw new Error(`Unexpected SQL: ${sql}`);
return [[]];
},
select: () => ({
from: () => ({
where: () => ({
@@ -62,6 +92,7 @@ import { createArticle, updateArticle } from "./admin-articles";
function form(extra: Record<string, string> = {}) {
const f = new FormData();
for (const [k, v] of Object.entries({
requestKey: "11111111-1111-4111-8111-111111111111",
title: "Test news",
slug: "test-news",
shortStory: "Summary",
@@ -76,8 +107,30 @@ describe("news saves", () => {
beforeEach(() => {
vi.clearAllMocks();
state.rows = [];
state.operations.clear();
state.effects = [];
state.invalidate.mockReset();
state.insert.mockResolvedValue([{ insertId: 1 }]);
});
it("replays a committed creation without another article or notification", async () => {
const first = await createArticle(form());
const retry = await createArticle(form());
expect(first.ok).toBe(true);
expect(retry).toEqual(first);
expect(state.insert).toHaveBeenCalledOnce();
expect(state.notify).toHaveBeenCalledOnce();
expect(state.effects).toHaveLength(1);
});
it("returns success when cache refresh fails after the article commits", async () => {
state.invalidate.mockRejectedValue(Error("cache unavailable"));
await expect(createArticle(form())).resolves.toMatchObject({ ok: true });
expect(state.effects).toHaveLength(1);
});
it("rejects reuse of a committed request key with different content", async () => {
expect((await createArticle(form())).ok).toBe(true);
expect((await createArticle(form({ title: "Different" }))).ok).toBe(false);
expect(state.insert).toHaveBeenCalledOnce();
});
it("preserves a recoverable error and logs its reference", async () => {
state.insert.mockRejectedValueOnce(Error("Unknown column status"));
const result = await createArticle(form());
@@ -132,6 +185,58 @@ describe("news saves", () => {
);
expect(state.invalidate).toHaveBeenCalledOnce();
});
it("replays a committed update without stale-token failure or another revision", async () => {
const existing = {
title: "Old",
slug: "test-news",
image: "",
shortStory: "",
fullStory: "",
status: "draft",
publishAt: null,
publishedAt: null,
};
state.rows = [[existing]];
const data = form({ id: "1", baseToken: articleEditToken(existing) });
const first = await updateArticle(data);
expect(first.ok).toBe(true);
expect(await updateArticle(data)).toEqual(first);
expect(state.update).toHaveBeenCalledOnce();
expect(state.insert).toHaveBeenCalledOnce();
expect(state.notify).toHaveBeenCalledOnce();
expect(state.effects).toHaveLength(1);
});
it("accepts long article bodies while detecting body changes on replay", async () => {
const body = "a".repeat(100_000);
expect((await createArticle(form({ fullStory: body }))).ok).toBe(true);
expect((await createArticle(form({ fullStory: `${body}b` }))).ok).toBe(
false,
);
expect(state.insert).toHaveBeenCalledOnce();
});
it("preserves the scheduled instant without publishing early", async () => {
expect(
(
await createArticle(
form({ status: "scheduled", publishAt: "2099-01-01T00:00:00Z" }),
)
).ok,
).toBe(true);
expect(state.insert).toHaveBeenCalledWith(
expect.objectContaining({
status: "scheduled",
publishAt: new Date("2099-01-01T00:00:00Z"),
publishedAt: null,
}),
);
expect(state.notify).not.toHaveBeenCalled();
});
it("does not report a committed article as failed when notification dispatch throws", async () => {
state.notify.mockImplementationOnce(() => {
throw Error("notification failure");
});
await expect(createArticle(form())).resolves.toMatchObject({ ok: true });
});
it("rejects an outdated editor without inserting a revision or overwriting the article", async () => {
state.rows = [
[