import type { SQL } from "drizzle-orm"; import { MySqlDialect } from "drizzle-orm/mysql-core"; import { format } from "mysql2"; import { beforeEach, expect, it, vi } from "vitest"; const state = vi.hoisted(() => ({ articles: [] as Array<{ id: string; userId: number | null; status: string; publishAt: Date; }>, queries: [] as Array<{ sql: string; params: unknown[] }>, operations: [] as unknown[][], effects: [] as unknown[][], failOutbox: false, loseRace: false, refresh: vi.fn(), })); vi.mock("@/lib/logger", () => ({ logger: { error: vi.fn() } })); vi.mock("./news-cache", () => ({ invalidateNewsCache: state.refresh })); vi.mock("@/lib/db", () => { const dialect = new MySqlDialect(); const timestamp = (value: unknown) => value instanceof Date ? value : new Date(`${String(value).replace(" ", "T")}Z`); const tx = { execute: async (query: SQL) => { const { sql, params } = dialect.sqlToQuery(query); state.queries.push({ sql, params }); if (sql.startsWith("SELECT")) return [ state.articles .filter( (a) => a.status === "scheduled" && a.publishAt <= timestamp(params[0]), ) .slice(0, 100), ]; if (sql.startsWith("UPDATE website_articles")) { const article = state.articles.find((a) => a.id === params[2]); if ( state.loseRace || !article || article.status !== "scheduled" || article.publishAt > timestamp(params[3]) ) return [{ affectedRows: 0 }]; article.status = "published"; return [{ affectedRows: 1 }]; } if (sql.startsWith("INSERT INTO cms_operations")) state.operations.push(params); else if (sql.startsWith("INSERT INTO cms_outbox")) { if (state.failOutbox) throw Error("outbox unavailable"); state.effects.push(params); } else throw Error(`Unexpected SQL ${sql}`); return [[]]; }, }; return { db: { transaction: async (work: (tx: unknown) => Promise) => { const before = structuredClone({ articles: state.articles, operations: state.operations, effects: state.effects, }); try { return await work(tx); } catch (error) { Object.assign(state, before); throw error; } }, }, }; }); import { publishDueArticles } from "./news-scheduler"; const now = new Date("2030-01-02T00:00:00Z"); beforeEach(() => { state.articles = [ { id: "1", userId: 0, status: "scheduled", publishAt: new Date("2030-01-01T00:00:00Z"), }, ]; state.queries = []; state.operations = []; state.effects = []; state.failOutbox = false; state.loseRace = false; state.refresh.mockReset(); }); it("publishes once with the original legacy author and a durable cache refresh", async () => { expect(await publishDueArticles(now)).toBe(1); expect(await publishDueArticles(now)).toBe(0); expect(state.articles[0].status).toBe("published"); expect(state.operations).toHaveLength(1); expect(state.operations[0][1]).toBe(0); expect(state.effects).toHaveLength(1); expect(state.effects[0][2]).toBe("news.refresh"); }); it("rolls publication back when durable cache enqueue fails", async () => { state.failOutbox = true; await expect(publishDueArticles(now)).rejects.toThrow("outbox unavailable"); expect(state.articles[0].status).toBe("scheduled"); expect(state.operations).toEqual([]); expect(state.effects).toEqual([]); }); it("leaves future articles and concurrent changed articles alone", async () => { state.articles[0].publishAt = new Date("2031-01-01T00:00:00Z"); expect(await publishDueArticles(now)).toBe(0); state.articles[0].publishAt = new Date("2030-01-01T00:00:00Z"); state.loseRace = true; expect(await publishDueArticles(now)).toBe(0); expect(state.operations).toEqual([]); expect(state.effects).toEqual([]); }); it("records real authors and represents missing legacy authors as system", async () => { state.articles[0].userId = null; state.articles.push({ ...state.articles[0], id: "2", userId: 42 }); expect(await publishDueArticles(now)).toBe(2); expect(state.operations.map((row) => row[1])).toEqual([0, 42]); }); it("reports committed publication even when immediate cache refresh fails", async () => { state.refresh.mockRejectedValue(Error("cache offline")); expect(await publishDueArticles(now)).toBe(1); expect(state.articles[0].status).toBe("published"); expect(state.effects).toHaveLength(1); expect(state.refresh).toHaveBeenCalledOnce(); }); it("uses the same UTC cutoff and publication timestamps regardless of the MySQL client timezone", async () => { const instant = new Date("2030-06-01T08:09:10.123Z"); expect(await publishDueArticles(instant)).toBe(1); const statements = state.queries.filter( ({ sql }) => sql.startsWith("SELECT") || sql.startsWith("UPDATE website_articles"), ); expect(statements).toHaveLength(2); for (const statement of statements) { expect(format(statement.sql, statement.params, false, "+02:00")).toBe( format(statement.sql, statement.params, false, "-07:00"), ); } expect(statements[0].params).toEqual(["2030-06-01 08:09:10.123"]); expect(statements[1].params).toEqual([ "2030-06-01 08:09:10.123", "2030-06-01 08:09:10.123", "1", "2030-06-01 08:09:10.123", ]); });