WIP: Unified Housekeeping rebuild / Gecombineerde Housekeeping-herbouw #53
No files matched your search
+171
-8
File diff suppressed because it is too large.
Load diff
+172
-34
File diff suppressed because it is too large.
Load diff
+52
-2
@@ -486,14 +486,17 @@ describe("Content database mutation runtime partial updates", () => {
|
||||
});
|
||||
|
||||
it("canonicalizes submitted question options and preserves omitted columns", async () => {
|
||||
database.selected({
|
||||
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)
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
export const POLL_QUESTION_TYPES = ["single", "multiple", "text"] as const;
|
||||
export type PollQuestionType = (typeof POLL_QUESTION_TYPES)[number];
|
||||
export const POLL_PUBLIC_QUESTION_LIMIT = 50;
|
||||
export const POLL_DRAFT_QUESTION_LIMIT = 100;
|
||||
export const POLL_ANSWER_MAX_LENGTH = 500;
|
||||
|
||||
export function parsePollOptions(value: string): string[] {
|
||||
return value
|
||||
@@ -24,3 +27,33 @@ export function parsePollAnswerSelections(
|
||||
? parsePollOptions(normalized)
|
||||
: [normalized.trim()].filter(Boolean);
|
||||
}
|
||||
|
||||
export function pollQuestionLimitReached(
|
||||
status: string,
|
||||
currentCount: number,
|
||||
): boolean {
|
||||
const limit =
|
||||
status === "active"
|
||||
? POLL_PUBLIC_QUESTION_LIMIT
|
||||
: POLL_DRAFT_QUESTION_LIMIT;
|
||||
return currentCount >= limit;
|
||||
}
|
||||
|
||||
interface PollQuestionSemantics {
|
||||
readonly question: string;
|
||||
readonly type: PollQuestionType;
|
||||
readonly options: string;
|
||||
}
|
||||
|
||||
export function hasPollQuestionSemanticChange(
|
||||
before: PollQuestionSemantics,
|
||||
after: PollQuestionSemantics,
|
||||
): boolean {
|
||||
return (
|
||||
before.question.normalize("NFC").trim() !==
|
||||
after.question.normalize("NFC").trim() ||
|
||||
before.type !== after.type ||
|
||||
serializePollOptions(before.type, before.options) !==
|
||||
serializePollOptions(after.type, after.options)
|
||||
);
|
||||
}
|
||||
@@ -112,6 +112,50 @@ describe("pollQuestionSchema", () => {
|
||||
}).success,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps every single-choice option compatible with the public answer limit", () => {
|
||||
const exactly500 = `A${"x".repeat(499)}`;
|
||||
const tooLong = `B${"x".repeat(500)}`;
|
||||
|
||||
expect(
|
||||
pollQuestionSchema.safeParse({
|
||||
pollId: 1,
|
||||
question: "Boundary?",
|
||||
type: "single",
|
||||
options: `${exactly500}\nShort`,
|
||||
}).success,
|
||||
).toBe(true);
|
||||
expect(
|
||||
pollQuestionSchema.safeParse({
|
||||
pollId: 1,
|
||||
question: "Boundary?",
|
||||
type: "single",
|
||||
options: `${tooLong}\nShort`,
|
||||
}).success,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("bounds the possible serialized multiple-choice answer at 500 characters", () => {
|
||||
const exactly500 = `${"A".repeat(249)}\n${"B".repeat(250)}`;
|
||||
const tooLong = `${"A".repeat(250)}\n${"B".repeat(250)}`;
|
||||
|
||||
expect(
|
||||
pollQuestionSchema.safeParse({
|
||||
pollId: 1,
|
||||
question: "Boundary?",
|
||||
type: "multiple",
|
||||
options: exactly500,
|
||||
}).success,
|
||||
).toBe(true);
|
||||
expect(
|
||||
pollQuestionSchema.safeParse({
|
||||
pollId: 1,
|
||||
question: "Boundary?",
|
||||
type: "multiple",
|
||||
options: tooLong,
|
||||
}).success,
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
describe("poll schedule validation", () => {
|
||||
it("requires the end time to be later than the start time", () => {
|
||||
@@ -145,6 +189,17 @@ describe("pollVoteSchema", () => {
|
||||
pollVoteSchema.safeParse({ questionId: 0, answer: "x" }).success,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts 500 characters and rejects 501", () => {
|
||||
expect(
|
||||
pollVoteSchema.safeParse({ questionId: 5, answer: "A".repeat(500) })
|
||||
.success,
|
||||
).toBe(true);
|
||||
expect(
|
||||
pollVoteSchema.safeParse({ questionId: 5, answer: "A".repeat(501) })
|
||||
.success,
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("voteOnPollSchema", () => {
|
||||
|
||||
+42
-11
@@ -1,5 +1,6 @@
|
||||
import { z } from "zod";
|
||||
import {
|
||||
POLL_ANSWER_MAX_LENGTH,
|
||||
POLL_QUESTION_TYPES,
|
||||
parsePollOptions,
|
||||
} from "@/lib/polls/poll-semantics";
|
||||
@@ -7,9 +8,9 @@ import {
|
||||
const pollFields = z.object({
|
||||
title: z.string().trim().min(1, "Title is required").max(255),
|
||||
description: z.string().max(2_000).nullable().optional(),
|
||||
status: z.enum(["draft", "active", "closed"]).default("draft"),
|
||||
showResults: z.coerce.number().int().min(0).max(1).default(1),
|
||||
multipleChoice: z.coerce.number().int().min(0).max(1).default(0),
|
||||
status: z.enum(["draft", "active", "closed"]),
|
||||
showResults: z.coerce.number().int().min(0).max(1),
|
||||
multipleChoice: z.coerce.number().int().min(0).max(1),
|
||||
startsAt: z.coerce.date().nullable().optional(),
|
||||
endsAt: z.coerce.date().nullable().optional(),
|
||||
});
|
||||
@@ -27,7 +28,13 @@ function validateSchedule(
|
||||
}
|
||||
}
|
||||
|
||||
export const createPollSchema = pollFields.superRefine(validateSchedule);
|
||||
export const createPollSchema = pollFields
|
||||
.extend({
|
||||
status: pollFields.shape.status.default("draft"),
|
||||
showResults: pollFields.shape.showResults.default(1),
|
||||
multipleChoice: pollFields.shape.multipleChoice.default(0),
|
||||
})
|
||||
.superRefine(validateSchedule);
|
||||
export const updatePollSchema = pollFields
|
||||
.partial()
|
||||
.superRefine(validateSchedule);
|
||||
@@ -35,9 +42,9 @@ export const updatePollSchema = pollFields
|
||||
const pollQuestionFields = z.object({
|
||||
pollId: z.coerce.number().int().positive(),
|
||||
question: z.string().trim().min(1).max(500),
|
||||
type: z.enum(POLL_QUESTION_TYPES).default("single"),
|
||||
sortOrder: z.coerce.number().int().min(0).default(0),
|
||||
options: z.string().max(20_000).default(""),
|
||||
type: z.enum(POLL_QUESTION_TYPES),
|
||||
sortOrder: z.coerce.number().int().min(0),
|
||||
options: z.string().max(20_000),
|
||||
});
|
||||
|
||||
function validateQuestionOptions(
|
||||
@@ -70,18 +77,42 @@ function validateQuestionOptions(
|
||||
path: ["options"],
|
||||
message: "At most 100 options are allowed",
|
||||
});
|
||||
if (
|
||||
data.type !== "text" &&
|
||||
options.some((option) => option.length > POLL_ANSWER_MAX_LENGTH)
|
||||
) {
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
path: ["options"],
|
||||
message: "Each option must fit the public answer limit",
|
||||
});
|
||||
}
|
||||
if (
|
||||
data.type === "multiple" &&
|
||||
options.join("\n").length > POLL_ANSWER_MAX_LENGTH
|
||||
) {
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
path: ["options"],
|
||||
message: "All selected options must fit the public answer limit",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const pollQuestionSchema = pollQuestionFields.superRefine(
|
||||
validateQuestionOptions,
|
||||
);
|
||||
export const pollQuestionSchema = pollQuestionFields
|
||||
.extend({
|
||||
type: pollQuestionFields.shape.type.default("single"),
|
||||
sortOrder: pollQuestionFields.shape.sortOrder.default(0),
|
||||
options: pollQuestionFields.shape.options.default(""),
|
||||
})
|
||||
.superRefine(validateQuestionOptions);
|
||||
export const pollQuestionPatchSchema = pollQuestionFields
|
||||
.omit({ pollId: true })
|
||||
.partial();
|
||||
|
||||
export const pollVoteSchema = z.object({
|
||||
questionId: z.coerce.number().int().positive(),
|
||||
answer: z.string().min(1).max(500),
|
||||
answer: z.string().min(1).max(POLL_ANSWER_MAX_LENGTH),
|
||||
});
|
||||
|
||||
export const voteOnPollSchema = z.object({
|
||||
|
||||
Reference in new issue
Block a user