Files
community-rule/tests/unit/createFlowValidation.test.ts
2026-05-09 23:12:11 -06:00

262 lines
6.9 KiB
TypeScript

import { describe, it, expect } from "vitest";
import {
assertPlainJsonValue,
DEFAULT_PLAIN_JSON_LIMITS,
} from "../../lib/server/validation/plainJson";
import {
createFlowStateSchema,
publishRuleBodySchema,
putDraftBodySchema,
uniqueStakeholderEmailsForPublish,
} from "../../lib/server/validation/createFlowSchemas";
describe("assertPlainJsonValue", () => {
it("accepts plain JSON structures", () => {
expect(
assertPlainJsonValue(
{ a: [1, "x", { b: null }], c: true },
0,
DEFAULT_PLAIN_JSON_LIMITS,
),
).toBeNull();
});
it("rejects __proto__ keys", () => {
const obj = JSON.parse('{"__proto__": {"x": 1}}') as Record<
string,
unknown
>;
expect(assertPlainJsonValue(obj, 0, DEFAULT_PLAIN_JSON_LIMITS)).toBe(
"Unsafe object key",
);
});
it("rejects non-finite numbers", () => {
expect(assertPlainJsonValue(Number.NaN, 0, DEFAULT_PLAIN_JSON_LIMITS)).toBe(
"Invalid number value",
);
});
it("rejects excessive depth", () => {
let v: unknown = 1;
for (let i = 0; i < 50; i++) {
v = { x: v };
}
expect(assertPlainJsonValue(v, 0, DEFAULT_PLAIN_JSON_LIMITS)).toBe(
"Maximum nesting depth exceeded",
);
});
});
describe("createFlowStateSchema", () => {
it("accepts empty object", () => {
const r = createFlowStateSchema.safeParse({});
expect(r.success).toBe(true);
});
it("accepts known fields and passthrough keys", () => {
const r = createFlowStateSchema.safeParse({
title: "My rule",
currentStep: "communication-methods",
customField: { nested: [1, 2] },
});
expect(r.success).toBe(true);
});
it("rejects invalid currentStep", () => {
const r = createFlowStateSchema.safeParse({ currentStep: "not-a-step" });
expect(r.success).toBe(false);
});
it("rejects title that is too long", () => {
const r = createFlowStateSchema.safeParse({ title: "x".repeat(600) });
expect(r.success).toBe(false);
});
it("rejects communitySaveEmail longer than 320 chars", () => {
const r = createFlowStateSchema.safeParse({
communitySaveEmail: "x".repeat(321),
});
expect(r.success).toBe(false);
});
it("rejects communityContext longer than 200 chars", () => {
const r = createFlowStateSchema.safeParse({
communityContext: "x".repeat(201),
});
expect(r.success).toBe(false);
});
it("accepts communityStructureChipSnapshots with custom chip rows", () => {
const r = createFlowStateSchema.safeParse({
communityStructureChipSnapshots: {
organizationTypes: [
{ id: "1", label: "Co-op", state: "selected" },
{ id: "custom-uuid", label: "My type", state: "selected" },
],
scale: [{ id: "1", label: "Local" }],
maturity: [],
},
});
expect(r.success).toBe(true);
});
it("rejects invalid chip snapshot row types", () => {
const r = createFlowStateSchema.safeParse({
communityStructureChipSnapshots: {
organizationTypes: [{ id: "1", label: 123 }],
},
});
expect(r.success).toBe(false);
});
it("accepts coreValueDetailsByChipId", () => {
const r = createFlowStateSchema.safeParse({
coreValueDetailsByChipId: {
"1": { meaning: "We care about access.", signals: "Blocking access." },
"uuid-here": { meaning: "", signals: "" },
},
});
expect(r.success).toBe(true);
});
it("accepts templateReviewBackSlug", () => {
const r = createFlowStateSchema.safeParse({
templateReviewBackSlug: "mutual-aid-mondays",
});
expect(r.success).toBe(true);
});
it("accepts customMethodCardFieldBlocksById", () => {
const r = createFlowStateSchema.safeParse({
customMethodCardFieldBlocksById: {
"card-uuid": [
{
kind: "text",
id: "f1",
blockTitle: "Notes",
placeholderText: "Optional",
},
{
kind: "badges",
id: "f2",
blockTitle: "Tags",
options: ["a", "b"],
},
{
kind: "upload",
id: "f3",
blockTitle: "Attachment",
fileName: "doc.pdf",
},
{
kind: "proportion",
id: "f4",
blockTitle: "Share",
defaultPercent: 50,
},
],
},
});
expect(r.success).toBe(true);
});
it("accepts templateReviewEntryFromCreateFlow", () => {
const r = createFlowStateSchema.safeParse({
templateReviewEntryFromCreateFlow: true,
});
expect(r.success).toBe(true);
});
it("rejects core value detail strings that are too long", () => {
const r = createFlowStateSchema.safeParse({
coreValueDetailsByChipId: {
"1": { meaning: "x".repeat(8001), signals: "y" },
},
});
expect(r.success).toBe(false);
});
it("accepts stakeholderEmails on draft payload", () => {
const r = createFlowStateSchema.safeParse({
stakeholderEmails: [" one@example.com "],
});
expect(r.success).toBe(true);
if (r.success) {
expect(r.data.stakeholderEmails).toEqual(["one@example.com"]);
}
});
});
describe("putDraftBodySchema", () => {
it("requires payload object", () => {
expect(putDraftBodySchema.safeParse({}).success).toBe(false);
expect(putDraftBodySchema.safeParse({ payload: {} }).success).toBe(true);
});
});
describe("publishRuleBodySchema", () => {
it("accepts minimal valid body", () => {
const r = publishRuleBodySchema.safeParse({
title: " Hello ",
document: { body: "text" },
});
expect(r.success).toBe(true);
if (r.success) {
expect(r.data.title).toBe("Hello");
expect(r.data.summary).toBeNull();
}
});
it("trims summary and maps empty to null", () => {
const r = publishRuleBodySchema.safeParse({
title: "T",
summary: " ",
document: {},
});
expect(r.success).toBe(true);
if (r.success) {
expect(r.data.summary).toBeNull();
}
});
it("rejects empty title", () => {
const r = publishRuleBodySchema.safeParse({
title: " ",
document: {},
});
expect(r.success).toBe(false);
});
it("rejects non-object document", () => {
const r = publishRuleBodySchema.safeParse({
title: "Ok",
document: "nope",
});
expect(r.success).toBe(false);
});
it("normalizes stakeholderEmails", () => {
const r = publishRuleBodySchema.safeParse({
title: "Ok",
document: {},
stakeholderEmails: [" A@Example.COM ", "b@example.com"],
});
expect(r.success).toBe(true);
if (r.success) {
expect(r.data.stakeholderEmails).toEqual(["a@example.com", "b@example.com"]);
}
});
});
describe("uniqueStakeholderEmailsForPublish", () => {
it("dedupes and drops publisher email", () => {
expect(
uniqueStakeholderEmailsForPublish(
["a@b.c", "A@B.C", "x@y.z"],
"a@b.c",
),
).toEqual(["x@y.z"]);
});
});