fix(news): persist publication requests and retry cache delivery
This commit is contained in:
1 parent
c977fe95ba
commit
52f6d1491f
40 files changed
+733
-134
No files matched your search
@@ -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 = [
|
||||
[
|
||||
|
||||
+136
-80
@@ -1,11 +1,13 @@
|
||||
"use server";
|
||||
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import { and, eq, ne } from "drizzle-orm";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { ArticleDrafts, ArticleRevisions } from "@/db/article-editor";
|
||||
import { historySnapshot, recordHistory } from "@/features/history/server";
|
||||
import { enqueueEffect, runOperation } from "@/features/operations/server";
|
||||
import { requirePermission } from "@/lib/admin/guard";
|
||||
import { articleEditToken } from "@/lib/article-edit-token";
|
||||
import {
|
||||
@@ -77,37 +79,77 @@ async function refreshNews() {
|
||||
revalidatePath("/me");
|
||||
revalidatePath("/");
|
||||
}
|
||||
function operationInput(
|
||||
form: FormData,
|
||||
input: ReturnType<typeof readArticleInput>,
|
||||
) {
|
||||
return {
|
||||
...input,
|
||||
fullStory: createHash("sha256").update(input.fullStory).digest("hex"),
|
||||
publishAt: input.publishAt?.toISOString() ?? null,
|
||||
id: String(form.get("id") ?? ""),
|
||||
baseToken: String(form.get("baseToken") ?? ""),
|
||||
};
|
||||
}
|
||||
async function refreshSavedNews(id?: bigint) {
|
||||
try {
|
||||
await refreshNews();
|
||||
if (id) revalidatePath(`/admin/articles/${id}`);
|
||||
} catch (error) {
|
||||
logger.error("News saved; cache refresh will be retried", {
|
||||
module: "news",
|
||||
error,
|
||||
});
|
||||
}
|
||||
}
|
||||
export async function createArticle(
|
||||
formData: FormData,
|
||||
): Promise<ArticleSaveResult> {
|
||||
const staff = await requirePermission(PERMS.NEWS_EDIT);
|
||||
let input: ReturnType<typeof readArticleInput>;
|
||||
let slug: string;
|
||||
let notification: Parameters<typeof notify>[0] | undefined;
|
||||
try {
|
||||
input = readArticleInput(formData);
|
||||
const { rawSlug, ...fields } = input;
|
||||
slug = await uniqueSlug(rawSlug || fields.title);
|
||||
const now = new Date();
|
||||
await db.insert(WebsiteArticles).values({
|
||||
...fields,
|
||||
slug,
|
||||
userId: staff.id,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
publishedAt: fields.status === "published" ? now : null,
|
||||
});
|
||||
const input = readArticleInput(formData);
|
||||
await runOperation(
|
||||
{
|
||||
actorId: staff.id,
|
||||
kind: "news.create",
|
||||
key: String(formData.get("requestKey") ?? randomUUID()),
|
||||
input: operationInput(formData, input),
|
||||
},
|
||||
async (tx, operationId) => {
|
||||
const { rawSlug, ...fields } = input;
|
||||
const slug = await uniqueSlug(rawSlug || fields.title, undefined, tx);
|
||||
const now = new Date();
|
||||
await tx.insert(WebsiteArticles).values({
|
||||
...fields,
|
||||
slug,
|
||||
userId: staff.id,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
publishedAt: fields.status === "published" ? now : null,
|
||||
});
|
||||
await enqueueEffect(tx, operationId, "news.refresh");
|
||||
if (input.status === "published")
|
||||
notification = {
|
||||
action: "news_publish",
|
||||
actor: staff.username,
|
||||
target: input.title,
|
||||
details: slug,
|
||||
};
|
||||
return { ok: true };
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
return saveFailure(error, "create");
|
||||
}
|
||||
// Auxiliary services must not turn a committed insert into an apparent failure.
|
||||
if (input.status === "published")
|
||||
notify({
|
||||
action: "news_publish",
|
||||
actor: staff.username,
|
||||
target: input.title,
|
||||
details: slug,
|
||||
});
|
||||
await refreshNews();
|
||||
if (notification) {
|
||||
try {
|
||||
notify(notification);
|
||||
} catch (error) {
|
||||
logger.error("News notification failed", { module: "news", error });
|
||||
}
|
||||
}
|
||||
await refreshSavedNews();
|
||||
return { ok: true, data: { redirectTo: "/admin/articles" } };
|
||||
}
|
||||
export async function updateArticle(
|
||||
@@ -122,66 +164,80 @@ export async function updateArticle(
|
||||
let slug = "";
|
||||
try {
|
||||
input = readArticleInput(formData);
|
||||
await db.transaction(async (tx) => {
|
||||
const [existing] = await tx
|
||||
.select()
|
||||
.from(WebsiteArticles)
|
||||
.where(eq(WebsiteArticles.id, id))
|
||||
.limit(1)
|
||||
.for("update");
|
||||
if (!existing) throw new ArticleInputError("articleNotFound");
|
||||
if (formData.get("baseToken") !== articleEditToken(existing))
|
||||
throw new ArticleInputError("editConflict");
|
||||
const historyId = String(id);
|
||||
const historyBefore = await historySnapshot(tx, "news", historyId);
|
||||
await tx.insert(ArticleRevisions).values({
|
||||
articleId: id,
|
||||
userId: staff.id,
|
||||
payload: JSON.stringify({
|
||||
title: existing.title,
|
||||
slug: existing.slug,
|
||||
image: existing.image,
|
||||
shortStory: existing.shortStory,
|
||||
fullStory: existing.fullStory,
|
||||
status: existing.status,
|
||||
publishAt: existing.publishAt?.toISOString() ?? "",
|
||||
baseToken: "",
|
||||
}),
|
||||
});
|
||||
const { rawSlug, ...fields } = input;
|
||||
const requestedSlug = rawSlug ? slugify(rawSlug) : existing.slug;
|
||||
slug =
|
||||
requestedSlug === existing.slug
|
||||
? existing.slug
|
||||
: await uniqueSlug(requestedSlug, id, tx);
|
||||
becamePublished =
|
||||
fields.status === "published" && existing.status !== "published";
|
||||
await tx
|
||||
.update(WebsiteArticles)
|
||||
.set({
|
||||
...fields,
|
||||
slug,
|
||||
publishedAt:
|
||||
fields.status === "published"
|
||||
? (existing.publishedAt ?? new Date())
|
||||
: null,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(WebsiteArticles.id, id));
|
||||
await recordHistory(tx, "news", historyId, staff.id, historyBefore);
|
||||
});
|
||||
await runOperation(
|
||||
{
|
||||
actorId: staff.id,
|
||||
kind: "news.update",
|
||||
key: String(formData.get("requestKey") ?? randomUUID()),
|
||||
input: operationInput(formData, input),
|
||||
},
|
||||
async (tx, operationId) => {
|
||||
const [existing] = await tx
|
||||
.select()
|
||||
.from(WebsiteArticles)
|
||||
.where(eq(WebsiteArticles.id, id))
|
||||
.limit(1)
|
||||
.for("update");
|
||||
if (!existing) throw new ArticleInputError("articleNotFound");
|
||||
if (formData.get("baseToken") !== articleEditToken(existing))
|
||||
throw new ArticleInputError("editConflict");
|
||||
const historyId = String(id);
|
||||
const historyBefore = await historySnapshot(tx, "news", historyId);
|
||||
await tx.insert(ArticleRevisions).values({
|
||||
articleId: id,
|
||||
userId: staff.id,
|
||||
payload: JSON.stringify({
|
||||
title: existing.title,
|
||||
slug: existing.slug,
|
||||
image: existing.image,
|
||||
shortStory: existing.shortStory,
|
||||
fullStory: existing.fullStory,
|
||||
status: existing.status,
|
||||
publishAt: existing.publishAt?.toISOString() ?? "",
|
||||
baseToken: "",
|
||||
}),
|
||||
});
|
||||
const { rawSlug, ...fields } = input;
|
||||
const requestedSlug = rawSlug ? slugify(rawSlug) : existing.slug;
|
||||
slug =
|
||||
requestedSlug === existing.slug
|
||||
? existing.slug
|
||||
: await uniqueSlug(requestedSlug, id, tx);
|
||||
becamePublished =
|
||||
fields.status === "published" && existing.status !== "published";
|
||||
await tx
|
||||
.update(WebsiteArticles)
|
||||
.set({
|
||||
...fields,
|
||||
slug,
|
||||
publishedAt:
|
||||
fields.status === "published"
|
||||
? (existing.publishedAt ?? new Date())
|
||||
: null,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(WebsiteArticles.id, id));
|
||||
await recordHistory(tx, "news", historyId, staff.id, historyBefore);
|
||||
await enqueueEffect(tx, operationId, "news.refresh");
|
||||
return { ok: true };
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
return saveFailure(error, "update");
|
||||
}
|
||||
if (becamePublished)
|
||||
notify({
|
||||
action: "news_publish",
|
||||
actor: staff.username,
|
||||
target: input.title,
|
||||
details: slug,
|
||||
});
|
||||
await refreshNews();
|
||||
revalidatePath(`/admin/articles/${id}`);
|
||||
if (becamePublished) {
|
||||
try {
|
||||
notify({
|
||||
action: "news_publish",
|
||||
actor: staff.username,
|
||||
target: input.title,
|
||||
details: slug,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("News notification failed", { module: "news", error });
|
||||
}
|
||||
}
|
||||
await refreshSavedNews(id);
|
||||
return { ok: true, data: { redirectTo: "/admin/articles" } };
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user