refactor(catalog): unify commands and embed guarded catalog workspace
This commit is contained in:
1 parent
16f35568f5
commit
193b6686a9
57 files changed
+3268
-728
No files matched your search
@@ -0,0 +1,319 @@
|
||||
import { type SQL, sql } from "drizzle-orm";
|
||||
import { MySqlDialect } from "drizzle-orm/mysql-core";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
offers: [
|
||||
{ id: 1, pageId: 10, itemIds: "12;23;12" },
|
||||
{ id: 2, pageId: 10, itemIds: "23" },
|
||||
],
|
||||
pages: [{ id: 10 }, { id: 11 }],
|
||||
bases: [{ id: 12 }, { id: 23 }],
|
||||
queries: [] as { sql: string; params: unknown[] }[],
|
||||
writes: 0,
|
||||
failAt: 0,
|
||||
commits: 0,
|
||||
rollbacks: 0,
|
||||
}));
|
||||
vi.mock("@/lib/db", async () => ({
|
||||
...(await import("@/db/schema")),
|
||||
db: {
|
||||
transaction: async (fn: (tx: unknown) => Promise<unknown>) => {
|
||||
const saved = state.writes;
|
||||
try {
|
||||
const result = await fn({
|
||||
execute: async (query: SQL) => {
|
||||
const compiled = new MySqlDialect().sqlToQuery(query);
|
||||
state.queries.push(compiled);
|
||||
if (compiled.sql.startsWith("SELECT")) {
|
||||
if (compiled.sql.includes("catalog_pages"))
|
||||
return [state.pages, []];
|
||||
if (compiled.sql.includes("items_base"))
|
||||
return [
|
||||
state.bases.filter((row) => compiled.params.includes(row.id)),
|
||||
[],
|
||||
];
|
||||
return [
|
||||
state.offers.filter((row) => compiled.params.includes(row.id)),
|
||||
[],
|
||||
];
|
||||
}
|
||||
state.writes++;
|
||||
if (state.failAt === state.writes)
|
||||
throw Error("database unavailable");
|
||||
return [{ affectedRows: 1, insertId: 40 }, []];
|
||||
},
|
||||
});
|
||||
state.commits++;
|
||||
return result;
|
||||
} catch (error) {
|
||||
state.writes = saved;
|
||||
state.rollbacks++;
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
import {
|
||||
createBcOfferCommand,
|
||||
createOfferCommand,
|
||||
moveOffersCommand,
|
||||
reorderOffersCommand,
|
||||
updateBcOfferCommand,
|
||||
updateOfferCommand,
|
||||
} from "./offer-commands";
|
||||
|
||||
beforeEach(() => {
|
||||
state.pages = [{ id: 10 }, { id: 11 }];
|
||||
state.queries = [];
|
||||
state.writes = 0;
|
||||
state.failAt = 0;
|
||||
state.commits = 0;
|
||||
state.rollbacks = 0;
|
||||
});
|
||||
describe("transactional offer commands", () => {
|
||||
it("rolls back offer and shared furniture writes together", async () => {
|
||||
state.failAt = 2;
|
||||
await expect(
|
||||
updateOfferCommand({
|
||||
id: 1,
|
||||
catalogFields: { pageId: 11, costCredits: 0 },
|
||||
baseItem: { id: 23, fields: { publicName: "New" } },
|
||||
}),
|
||||
).rejects.toThrow("database unavailable");
|
||||
expect(state.writes).toBe(0);
|
||||
expect(state.rollbacks).toBe(1);
|
||||
});
|
||||
it("prevents writing an unrelated shared furniture even when submitted as a new bundle member", async () => {
|
||||
await expect(
|
||||
updateOfferCommand({
|
||||
id: 1,
|
||||
catalogFields: { itemIds: "12;99" },
|
||||
baseItem: { id: 99, fields: { publicName: "Wrong" } },
|
||||
}),
|
||||
).rejects.toThrow("belong");
|
||||
expect(state.writes).toBe(0);
|
||||
});
|
||||
it("updates a non-first bundle component and preserves zero values", async () => {
|
||||
await updateOfferCommand({
|
||||
id: 1,
|
||||
catalogFields: { pageId: 11, amount: 0, offerId: 0, orderNumber: 0 },
|
||||
baseItem: { id: 23, fields: { publicName: "New" } },
|
||||
});
|
||||
expect(state.commits).toBe(1);
|
||||
expect(state.writes).toBe(2);
|
||||
expect(
|
||||
state.queries.find((q) => q.sql.startsWith("UPDATE `catalog_items`"))
|
||||
?.params,
|
||||
).toContain("11");
|
||||
});
|
||||
it("rejects missing destination pages before writes", async () => {
|
||||
state.pages = [];
|
||||
await expect(
|
||||
updateOfferCommand({ id: 1, catalogFields: { pageId: 11 } }),
|
||||
).rejects.toThrow("page");
|
||||
expect(state.writes).toBe(0);
|
||||
});
|
||||
it("rejects missing bundle references before writes", async () => {
|
||||
await expect(
|
||||
updateOfferCommand({ id: 1, catalogFields: { itemIds: "12;99" } }),
|
||||
).rejects.toThrow("Furniture");
|
||||
expect(state.writes).toBe(0);
|
||||
});
|
||||
it("rolls back the entire reorder after an intermediate failure", async () => {
|
||||
state.failAt = 2;
|
||||
await expect(
|
||||
reorderOffersCommand([
|
||||
{ id: 1, orderNumber: 0 },
|
||||
{ id: 2, orderNumber: 1 },
|
||||
]),
|
||||
).rejects.toThrow("database unavailable");
|
||||
expect(state.writes).toBe(0);
|
||||
expect(state.rollbacks).toBe(1);
|
||||
});
|
||||
it("rejects duplicate reorder ids", async () => {
|
||||
await expect(
|
||||
reorderOffersCommand([
|
||||
{ id: 1, orderNumber: 0 },
|
||||
{ id: 1, orderNumber: 1 },
|
||||
]),
|
||||
).rejects.toThrow();
|
||||
expect(state.writes).toBe(0);
|
||||
});
|
||||
it("rejects missing offers in a batch", async () => {
|
||||
await expect(
|
||||
reorderOffersCommand([
|
||||
{ id: 1, orderNumber: 0 },
|
||||
{ id: 99, orderNumber: 1 },
|
||||
]),
|
||||
).rejects.toThrow("not found");
|
||||
expect(state.writes).toBe(0);
|
||||
});
|
||||
it("validates all source offers before moving a batch", async () => {
|
||||
await expect(moveOffersCommand([1, 99], 11)).rejects.toThrow("not found");
|
||||
expect(state.writes).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
it("does not update furniture removed from the submitted bundle", async () => {
|
||||
await expect(
|
||||
updateOfferCommand({
|
||||
id: 1,
|
||||
catalogFields: { itemIds: "12" },
|
||||
baseItem: { id: 23, fields: { publicName: "Wrong" } },
|
||||
}),
|
||||
).rejects.toThrow("belong");
|
||||
expect(state.writes).toBe(0);
|
||||
});
|
||||
it("keeps explicit zeros in the actual offer SQL parameters", async () => {
|
||||
await updateOfferCommand({
|
||||
id: 1,
|
||||
catalogFields: {
|
||||
amount: 0,
|
||||
orderNumber: 0,
|
||||
offerId: 0,
|
||||
limitedSells: 0,
|
||||
limitedStack: 0,
|
||||
},
|
||||
});
|
||||
const update = state.queries.find((query) => query.sql.startsWith("UPDATE"));
|
||||
expect(update?.params).toEqual([0, 0, 0, 0, 0, 1]);
|
||||
});
|
||||
it("rejects unsafe ids before opening a transaction", async () => {
|
||||
await expect(
|
||||
updateOfferCommand({ id: 0, catalogFields: { amount: 1 } }),
|
||||
).rejects.toThrow();
|
||||
await expect(
|
||||
reorderOffersCommand([{ id: 1.5, orderNumber: 1 }]),
|
||||
).rejects.toThrow();
|
||||
expect(state.queries).toHaveLength(0);
|
||||
});
|
||||
it("uses one atomic update and a string page parameter for a move", async () => {
|
||||
await moveOffersCommand([1, 2], 11);
|
||||
expect(state.commits).toBe(1);
|
||||
expect(state.writes).toBe(1);
|
||||
expect(
|
||||
state.queries.find((query) => query.sql.startsWith("UPDATE"))?.params,
|
||||
).toEqual(["11", 1, 2]);
|
||||
});
|
||||
it("ignores noneditable furniture and offer fields", async () => {
|
||||
await expect(
|
||||
updateOfferCommand({
|
||||
id: 1,
|
||||
catalogFields: { id: 2 },
|
||||
baseItem: { id: 12, fields: { spriteId: 99 } },
|
||||
}),
|
||||
).rejects.toThrow("No valid fields");
|
||||
expect(state.queries).toHaveLength(0);
|
||||
});
|
||||
|
||||
// Creation must retain the parent lock until its insert commits.
|
||||
it.each(["normal", "bc"] as const)(
|
||||
"rejects a deleted parent before a %s creation callback runs",
|
||||
async (kind) => {
|
||||
state.pages = [];
|
||||
const insert = vi.fn();
|
||||
await expect(
|
||||
createOfferCommand(kind, { pageId: 11, itemIds: "12;23" }, insert),
|
||||
).rejects.toThrow("page");
|
||||
expect(insert).not.toHaveBeenCalled();
|
||||
expect(state.writes).toBe(0);
|
||||
},
|
||||
);
|
||||
it("rejects missing furniture before creating an offer", async () => {
|
||||
const insert = vi.fn();
|
||||
await expect(
|
||||
createOfferCommand("normal", { pageId: 11, itemIds: "12;99" }, insert),
|
||||
).rejects.toThrow("Furniture");
|
||||
expect(insert).not.toHaveBeenCalled();
|
||||
});
|
||||
it("runs creation insert under the same transaction after reference locks", async () => {
|
||||
await createOfferCommand(
|
||||
"normal",
|
||||
{ pageId: 11, itemIds: "12;23;12" },
|
||||
async (tx) => {
|
||||
await tx.execute(sql`INSERT INTO catalog_items (id) VALUES (40)`);
|
||||
return 40;
|
||||
},
|
||||
);
|
||||
expect(state.queries.map((query) => query.sql.split(" ")[0])).toEqual([
|
||||
"SELECT",
|
||||
"SELECT",
|
||||
"INSERT",
|
||||
]);
|
||||
expect(state.queries[0].sql).toContain("FOR UPDATE");
|
||||
expect(state.queries[1].sql).toContain("FOR UPDATE");
|
||||
expect(state.commits).toBe(1);
|
||||
});
|
||||
it("rolls back a failed creation insert", async () => {
|
||||
state.failAt = 1;
|
||||
await expect(
|
||||
createOfferCommand("normal", { pageId: 11, itemIds: "12" }, (tx) =>
|
||||
tx.execute(sql`INSERT INTO catalog_items (id) VALUES (40)`),
|
||||
),
|
||||
).rejects.toThrow("database unavailable");
|
||||
expect(state.rollbacks).toBe(1);
|
||||
expect(state.writes).toBe(0);
|
||||
});
|
||||
it("rejects missing BC offers instead of reporting a zero-row update as saved", async () => {
|
||||
await expect(
|
||||
updateBcOfferCommand(99, { catalogName: "Missing" }),
|
||||
).rejects.toThrow("not found");
|
||||
expect(state.writes).toBe(0);
|
||||
});
|
||||
it("keeps BC fields explicit and uses BC destination locks", async () => {
|
||||
await updateBcOfferCommand(1, {
|
||||
pageId: 11,
|
||||
itemIds: "12;23",
|
||||
orderNumber: 0,
|
||||
costCredits: 99,
|
||||
});
|
||||
expect(state.queries[0].sql).toContain("catalog_pages_bc");
|
||||
const update = state.queries.find((query) => query.sql.startsWith("UPDATE"));
|
||||
expect(update?.sql).toContain("catalog_items_bc");
|
||||
expect(update?.sql).not.toContain("cost_credits");
|
||||
expect(update?.params).toEqual(["11", "12;23", 0, 1]);
|
||||
});
|
||||
|
||||
it("creates a BC offer with only its supported fields and returns the committed id", async () => {
|
||||
const id = await createBcOfferCommand({
|
||||
pageId: 11,
|
||||
itemIds: "12;23;12",
|
||||
catalogName: "Bundle",
|
||||
orderNumber: 0,
|
||||
extradata: "x;y",
|
||||
costCredits: 99,
|
||||
});
|
||||
expect(id).toBe(40);
|
||||
expect(state.commits).toBe(1);
|
||||
const insert = state.queries.find((query) => query.sql.startsWith("INSERT"));
|
||||
expect(insert?.sql).toContain("catalog_items_bc");
|
||||
expect(insert?.sql).not.toContain("cost_credits");
|
||||
expect(insert?.params).toEqual(["11", "12;23;12", "Bundle", 0, "x;y"]);
|
||||
});
|
||||
it("rejects incomplete BC creation before beginning a transaction", async () => {
|
||||
await expect(
|
||||
createBcOfferCommand({ pageId: 11, itemIds: "12" }),
|
||||
).rejects.toThrow();
|
||||
expect(state.queries).toHaveLength(0);
|
||||
});
|
||||
it("rolls back failed BC creation and does not return an id", async () => {
|
||||
state.failAt = 1;
|
||||
await expect(
|
||||
createBcOfferCommand({
|
||||
pageId: 11,
|
||||
itemIds: "12",
|
||||
catalogName: "One",
|
||||
orderNumber: 0,
|
||||
extradata: "",
|
||||
}),
|
||||
).rejects.toThrow("database unavailable");
|
||||
expect(state.commits).toBe(0);
|
||||
expect(state.rollbacks).toBe(1);
|
||||
});
|
||||
it("rejects a missing BC destination before touching the offer", async () => {
|
||||
state.pages = [];
|
||||
await expect(updateBcOfferCommand(1, { pageId: 11 })).rejects.toThrow("page");
|
||||
expect(state.writes).toBe(0);
|
||||
});
|
||||
Reference in new issue
Block a user