fix(polls): enforce shared persistence invariants

This commit is contained in:
Simo committed 2026-09-02 22:03:26 +02:00
1 parent c841595458
commit 50598472ad
7 files changed
+655 -169

No files matched your search

@@ -486,14 +486,17 @@ describe("Content database mutation runtime partial updates", () => {
});
it("canonicalizes submitted question options and preserves omitted columns", async () => {
database.selected({
id: 7,
pollId: 3,
question: "Favourite colour?",
type: "single",
sortOrder: 0,
options: "Red\nBlue",
});
database.queueSelected(
{
id: 7,
pollId: 3,
question: "Favourite colour?",
type: "single",
sortOrder: 0,
options: "Red\nBlue",
},
[],
);
await executeContentDatabaseMutation(
"poll-question.change",
@@ -508,6 +511,53 @@ describe("Content database mutation runtime partial updates", () => {
]);
});
it("rejects semantic question edits after votes exist", async () => {
database.queueSelected(
{
id: 7,
pollId: 3,
question: "Favourite colour?",
type: "single",
sortOrder: 0,
options: "Red\nBlue",
},
{ id: 91 },
);
await expect(
executeContentDatabaseMutation(
"poll-question.change",
{ action: "update", id: 7, options: "Green\nYellow" },
context,
undefined,
),
).rejects.toMatchObject({ code: "CONFLICT" });
expect(database.set).not.toHaveBeenCalled();
});
it("allows sort-order-only question edits after votes exist", async () => {
database.queueSelected(
{
id: 7,
pollId: 3,
question: "Favourite colour?",
type: "single",
sortOrder: 0,
options: "Red\nBlue",
},
{ id: 91 },
);
await executeContentDatabaseMutation(
"poll-question.change",
{ action: "update", id: 7, sortOrder: 2 },
context,
undefined,
);
expect(database.set).toHaveBeenCalledWith({ sortOrder: 2 });
});
it("blocks deletion of an event type referenced by an event", async () => {
database.queueSelected({ id: 7, name: "Tournament" }, { id: 11 });
@@ -26,7 +26,13 @@ import {
} from "@/lib/db";
import { slugify } from "@/lib/format";
import { canonicalize } from "@/lib/foundation/security";
import { serializePollOptions } from "@/lib/polls/poll-semantics";
import {
hasPollQuestionSemanticChange,
POLL_PUBLIC_QUESTION_LIMIT,
type PollQuestionType,
pollQuestionLimitReached,
serializePollOptions,
} from "@/lib/polls/poll-semantics";
import { logStaffActivity } from "@/lib/services/staff-activity";
import {
createEventSchema,
@@ -682,7 +688,7 @@ async function pollChange(
if (!merged.success) throw validation(merged.error);
if (
merged.data.status === "active" &&
(await pollQuestionCount(connection, id)) > 50
(await pollQuestionCount(connection, id)) > POLL_PUBLIC_QUESTION_LIMIT
) {
throw conflict();
}
@@ -713,10 +719,7 @@ async function pollQuestionChange(
.for("update");
if (!parent) throw notFound();
const questionCount = await pollQuestionCount(connection, pollId);
if (
questionCount >= 100 ||
(parent.status === "active" && questionCount >= 50)
) {
if (pollQuestionLimitReached(parent.status, questionCount)) {
throw conflict();
}
const parsed = pollQuestionSchema.safeParse(data);
@@ -783,6 +786,19 @@ async function pollQuestionChange(
merged.data.options,
);
}
if (
hasPollQuestionSemanticChange(
{ ...existing, type: existing.type as PollQuestionType },
merged.data,
)
) {
const [vote] = await connection
.select({ id: WebsitePollVote.id })
.from(WebsitePollVote)
.where(eq(WebsitePollVote.questionId, id))
.limit(1);
if (vote) throw conflict();
}
await connection
.update(WebsitePollQuestion)
.set(values)