From c4d37c4edc48f524bde7bb0c9f02210a042a359c Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 18 Aug 2026 02:50:21 +0000 Subject: [PATCH 1/7] Give seeds and the listing form their own suites, and bound their loops The loop sweep left two files out because nothing mirrored them: their logic was only reachable through integration tests, so the mutation gate could not even start. Each now has a direct suite, and with that in place their freeze-capable loops get the same bounded treatment as the rest of src/. src/shared/seeds.ts: the unique-slug count loop walks a range and the chunked attendee inserts use chunk, so no step mutant can spin them. The new test/shared/seeds.test.ts pins what seeding really promises: the exact demo price set (now exported so the pin can name it), paid and free listings alternating, the first listing's 1/2/3-day price tiers, capacity equalling the booked quantities with a large draw that would expose a die stuck outside 1-4, the seed payment id embedding the booking's worth, and the missing-public-key refusal. The two direct createSeeds tests that lived in the integration file moved here. src/features/admin/listings-form.ts: the day-price read walks range(1, maxDays + 1). The new test/features/admin/listings-form.test.ts drives the real create/update resources end to end: defaults, minor-unit prices, datetime normalization, day prices honouring the duration bound, the invalid-day-price refusal, group ticks (and junk ids dropped), the feature-gated builder/logistics choices both off and on, slug normalization on update, and the daily-vs-standard empty-day policies. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Dpnsvq2v2q1DEfWsWQhQA1 --- TODO.md | 14 +- src/features/admin/listings-form.ts | 3 +- src/shared/seeds.ts | 15 +- test/features/admin/listings-form.test.ts | 201 ++++++++++++++++++++++ test/integration/server/seeds.test.ts | 34 +--- test/shared/seeds.test.ts | 130 ++++++++++++++ 6 files changed, 345 insertions(+), 52 deletions(-) create mode 100644 test/features/admin/listings-form.test.ts create mode 100644 test/shared/seeds.test.ts diff --git a/TODO.md b/TODO.md index 41adc4575b..a12e489bba 100644 --- a/TODO.md +++ b/TODO.md @@ -128,17 +128,9 @@ decision, not a patch. Starting points: the dates.ts restructure in this PR; `scripts/mutation/execution.ts` for the test stage the decision would live in. The follow-up sweep bounded the freeze-capable loops across the rest of `src/` -(`range`/`entries`/`chunk`/`reduce` shapes; see the `#fp` `range` helper). Two -files were left out because they have **no mirror direct test suite at all**, so -the mutation gate cannot even start on them — each needs its suite built first, -then the same bounded-loop treatment: - -- `src/features/admin/listings-form.ts` — `parseDayPricesFromForm`'s - `for (let n = 1; n <= maxDays; n++)` spins forever under `n++ → n--` (blank - day-price reads skip without ever ending the loop). -- `src/shared/seeds.ts` — the unique-slug count loop and the chunked attendee - loop both spin or grow without bound under a step-neutralising mutant - (`i++ → i--`, `offset += CHUNK_SIZE → /=`). +(`range`/`entries`/`chunk`/`reduce` shapes; see the `#fp` `range` helper), +including the two files that first needed mirror direct suites built +(`src/features/admin/listings-form.ts`, `src/shared/seeds.ts`). --- diff --git a/src/features/admin/listings-form.ts b/src/features/admin/listings-form.ts index a45a80151d..9bfa3f657a 100644 --- a/src/features/admin/listings-form.ts +++ b/src/features/admin/listings-form.ts @@ -7,6 +7,7 @@ */ /* jscpd:ignore-start */ +import { range } from "#fp"; import { projectCatalogFields } from "#shared/catalog-fields/definition.ts"; import { type ListingInput, @@ -107,7 +108,7 @@ const parseDayPricesFromForm = ( maxDays: number, ): DayPrices => { const result: DayPrices = {}; - for (let n = 1; n <= maxDays; n++) { + for (const n of range(1, maxDays + 1)) { // Optional per-day price: blank ⇒ skip (that day isn't offered). A non-blank // value that fails to parse is caught by validateDayPricesFromForm before // the save, so here a null result is only ever a blank. diff --git a/src/shared/seeds.ts b/src/shared/seeds.ts index c4cb59b2f2..89ed34a8ae 100644 --- a/src/shared/seeds.ts +++ b/src/shared/seeds.ts @@ -3,7 +3,7 @@ * Uses batch writes for efficient database operations. */ -import { map, sum } from "#fp"; +import { chunk, map, range, sum } from "#fp"; import { encrypt } from "#shared/crypto/encryption.ts"; import { hmacHash } from "#shared/crypto/hashing.ts"; import { generateTicketToken } from "#shared/crypto/utils.ts"; @@ -37,14 +37,15 @@ export const SEED_MAX_ATTENDEES = 100_000; /** Pick a random ticket quantity (1-4) */ const randomQuantity = (): number => 1 + Math.floor(Math.random() * 4); -/** Sample unit prices in minor units (e.g. pence/cents) for paid listings */ -const DEMO_UNIT_PRICES = [500, 1000, 1500, 2000, 2500, 3000, 5000]; +/** Sample unit prices in minor units (e.g. pence/cents) for paid listings. + * Exported so the seeds suite can pin the exact set a paid listing draws from. */ +export const DEMO_UNIT_PRICES = [500, 1000, 1500, 2000, 2500, 3000, 5000]; /** Generate slugs that are unique within the batch */ const generateUniqueSlugs = async (count: number): Promise => { const usedSlugs = new Set(); const results: SlugWithIndex[] = []; - for (let i = 0; i < count; i++) { + for (const _slot of range(0, count)) { const result = await generateUniqueSlug(hmacHash, (slug) => Promise.resolve(usedSlugs.has(slug)), ); @@ -232,9 +233,7 @@ export const createSeeds = async ( for (const [e, listingId] of listingIds.entries()) { const { quantities, unitPrice } = listingData[e]!; - for (let offset = 0; offset < attendeesPerListing; offset += CHUNK_SIZE) { - const batchSize = Math.min(CHUNK_SIZE, attendeesPerListing - offset); - const chunkQuantities = quantities.slice(offset, offset + batchSize); + for (const chunkQuantities of chunk(CHUNK_SIZE)(quantities)) { const statementPairs = await Promise.all( map((q: number) => prepareAttendee(listingId, q, unitPrice))( chunkQuantities, @@ -242,7 +241,7 @@ export const createSeeds = async ( ); // Each booking locates its attendee by the caller-supplied stable token. await executeBatch(statementPairs.flat()); - totalAttendees += batchSize; + totalAttendees += chunkQuantities.length; } } diff --git a/test/features/admin/listings-form.test.ts b/test/features/admin/listings-form.test.ts new file mode 100644 index 0000000000..4c4b8aaeb0 --- /dev/null +++ b/test/features/admin/listings-form.test.ts @@ -0,0 +1,201 @@ +import { expect } from "@std/expect"; +import { describe, it as test } from "@std/testing/bdd"; +import { + buildCreateListingResource, + buildUpdateListingResource, + extractListingAggregateValues, + parseGroupIds, +} from "#routes/admin/listings-form.ts"; +import { VALID_DAY_NAMES } from "#shared/day-names.ts"; +import { getDb } from "#shared/db/client.ts"; +import { getListingDayPrices } from "#shared/db/listing-prices.ts"; +import { computeSlugIndex } from "#shared/db/listings/table.ts"; +import type { Listing } from "#shared/types.ts"; +import { describeWithEnv } from "#test-utils/db.ts"; +import { createTestGroup } from "#test-utils/db-helpers/groups.ts"; +import { + type TestFormValues, + testFormParams, +} from "#test-utils/form-values.ts"; +import { featureSetting, withSetting } from "#test-utils/settings.ts"; + +const listingForm = (extra: TestFormValues = {}) => + testFormParams({ + max_attendees: "50", + max_quantity: "5", + name: "Parsed listing", + ...extra, + }); + +const createListing = async (extra: TestFormValues = {}): Promise => { + const form = listingForm(extra); + const result = await buildCreateListingResource(form).create(form); + if (!result.ok) throw new Error(`create failed: ${result.error}`); + return result.row; +}; + +const updateListing = async ( + id: number, + extra: TestFormValues = {}, +): Promise => { + const form = listingForm({ slug: "kept-slug", ...extra }); + const result = await buildUpdateListingResource(form).update(id, form); + if (!result.ok) throw new Error(`update failed: ${result.error}`); + return result.row; +}; + +describeWithEnv("listings form", { db: true }, () => { + describe("parseGroupIds", () => { + test("keeps only positive whole group ids", () => { + const form = testFormParams({ group_ids: ["3", "0", "-2", "abc", "7"] }); + expect(parseGroupIds(form)).toEqual([3, 7]); + }); + + test("is empty when no group is ticked", () => { + expect(parseGroupIds(testFormParams({}))).toEqual([]); + }); + }); + + describe("extractListingAggregateValues", () => { + test("keeps exactly the two aggregate columns", () => { + expect( + extractListingAggregateValues({ + booked_quantity: 7, + tickets_count: 3, + }), + ).toEqual({ booked_quantity: 7, tickets_count: 3 }); + }); + }); + + describe("create", () => { + test("a minimal form makes a free standard listing open on every day", () => + (async () => { + const row = await createListing(); + expect(row.name).toBe("Parsed listing"); + expect(row.listing_type).toBe("standard"); + expect(row.unit_price).toBe(0); + expect(row.max_attendees).toBe(50); + expect(row.max_quantity).toBe(5); + expect(row.bookable_days).toEqual([...VALID_DAY_NAMES]); + expect(row.closes_at).toBeNull(); + })()); + + test("a zero unit price stays an explicit zero", async () => { + const row = await createListing({ unit_price: "0" }); + expect(row.unit_price).toBe(0); + }); + + test("a priced form stores the currency's minor units", async () => { + const row = await createListing({ + max_price: "12.5", + unit_price: "12.34", + }); + expect(row.unit_price).toBe(1234); + expect(row.max_price).toBe(1250); + }); + + test("datetimes normalize to UTC, and blanks stay blank", async () => { + const dated = await createListing({ + closes_at_date: "2026-06-15", + closes_at_time: "14:30", + date_date: "2026-03-01", + date_time: "09:05", + }); + expect(dated.closes_at).toBe("2026-06-15T14:30:00.000Z"); + expect(dated.date).toBe("2026-03-01T09:05:00.000Z"); + + const blank = await createListing({ name: "Undated" }); + expect(blank.closes_at).toBeNull(); + expect(blank.date).toBe(""); + }); + + test("chosen bookable days are kept as chosen", async () => { + const row = await createListing({ + bookable_days: ["Monday", "Thursday"], + }); + expect(row.bookable_days).toEqual(["Monday", "Thursday"]); + }); + + test("day prices are read for days one up to the duration only", async () => { + const row = await createListing({ + day_price_1: "10", + day_price_2: "", + day_price_3: "30", + day_price_4: "40", + duration_days: "3", + }); + expect(await getListingDayPrices(row.id)).toEqual({ 1: 1000, 3: 3000 }); + }); + + test("without a duration, only the single-day price is read", async () => { + const row = await createListing({ + day_price_1: "15", + day_price_2: "20", + }); + expect(await getListingDayPrices(row.id)).toEqual({ 1: 1500 }); + }); + + test("an unreadable day price rejects the save with a plain message", async () => { + const form = listingForm({ day_price_1: "abc", duration_days: "2" }); + const result = await buildCreateListingResource(form).create(form); + expect(result).toEqual({ + error: "Enter a valid day price for each duration, or leave it blank.", + ok: false, + }); + }); + + test("ticked groups are saved; unticked junk ids are not", async () => { + const group = await createTestGroup({ name: "Form group" }); + const row = await createListing({ + group_ids: [String(group.id), "0", "-4"], + }); + const links = await getDb().execute({ + args: [row.id], + sql: "SELECT group_id FROM group_listings WHERE listing_id = ?", + }); + expect(links.rows.map((r) => r.group_id)).toEqual([group.id]); + }); + + test("builder and logistics choices stay off while their features are off", async () => { + const row = await createListing({ + assign_built_site: "1", + uses_logistics: "1", + }); + expect(row.assign_built_site).toBe(false); + expect(row.uses_logistics).toBe(false); + }); + + test("a logistics choice is kept when the feature is on", () => + withSetting(featureSetting("logistics"), async () => { + const on = await createListing({ uses_logistics: "1" }); + expect(on.uses_logistics).toBe(true); + // An unticked checkbox never reaches the form at all. + const off = await createListing({ name: "No logistics" }); + expect(off.uses_logistics).toBe(false); + })); + }); + + describe("update", () => { + test("the slug is normalized and its lookup code recomputed", async () => { + const created = await createListing(); + const row = await updateListing(created.id, { slug: " New-Slug " }); + expect(row.slug).toBe("new-slug"); + expect(row.slug_index).toBe(await computeSlugIndex("new-slug")); + }); + + test("a daily listing keeps an emptied day selection empty", async () => { + // A create with no days ticked opens every day by default... + const created = await createListing({ listing_type: "daily" }); + expect(created.bookable_days).toEqual([...VALID_DAY_NAMES]); + // ...but a daily update with none ticked means "no days", and stays so. + const row = await updateListing(created.id, { listing_type: "daily" }); + expect(row.bookable_days).toEqual([]); + }); + + test("a standard listing's emptied day selection reopens every day", async () => { + const created = await createListing({ bookable_days: ["Monday"] }); + const row = await updateListing(created.id, {}); + expect(row.bookable_days).toEqual([...VALID_DAY_NAMES]); + }); + }); +}); diff --git a/test/integration/server/seeds.test.ts b/test/integration/server/seeds.test.ts index d80dcf2cea..10220d7ae0 100644 --- a/test/integration/server/seeds.test.ts +++ b/test/integration/server/seeds.test.ts @@ -3,11 +3,8 @@ import { describe, it as test } from "@std/testing/bdd"; import { handleRequest } from "#routes"; import { decryptAttendees } from "#shared/db/attendees/pii.ts"; import { getAttendeesRaw } from "#shared/db/attendees/queries.ts"; -import { getDb } from "#shared/db/client.ts"; import { getAllListings } from "#shared/db/listings/records.ts"; -import { settings } from "#shared/db/settings.ts"; import { DEMO_NAMES } from "#shared/demo/samples.ts"; -import { createSeeds } from "#shared/seeds.ts"; import { assertAdminHtml, expectFlashRedirect, @@ -150,35 +147,8 @@ describeWithEnv("server (admin seeds)", { db: true }, () => { expect(listings[0]!.max_attendees).toBe(0); }); - test("seeds a customisable-days listing with day prices", async () => { - await createSeeds(1, 0); - const { getListingDayPrices } = await import( - "#shared/db/listing-prices.ts" - ); - const listings = await getAllListings(); - const customisable = listings.find((l) => l.customisable_days); - expect(customisable).toBeDefined(); - // The demo day prices are 1/2/3-day counts (day prices are no longer a - // listings column — they are seeded as day_count rows in listing_prices). - const dayPrices = customisable!.day_prices; - expect( - Object.keys(dayPrices) - .map(Number) - .sort((x, y) => x - y), - ).toEqual([1, 2, 3]); - // The projected value matches the stored day_count rows exactly. - expect(await getListingDayPrices(customisable!.id)).toEqual(dayPrices); - }); - - test("throws when public key is not configured", async () => { - // Remove public key to cause createSeeds to throw - await getDb().execute("DELETE FROM settings WHERE key = 'public_key'"); - settings.invalidateCache(); - - await expect(createSeeds(1, 0)).rejects.toThrow( - "Public key not configured", - ); - }); + // The direct createSeeds contracts (day prices, price alternation, the + // missing-public-key throw) live in test/shared/seeds.test.ts. test("can seed multiple times additively", async () => { // First seed diff --git a/test/shared/seeds.test.ts b/test/shared/seeds.test.ts new file mode 100644 index 0000000000..fd023b64ad --- /dev/null +++ b/test/shared/seeds.test.ts @@ -0,0 +1,130 @@ +import { expect } from "@std/expect"; +import { describe, it as test } from "@std/testing/bdd"; +import { sum } from "#fp"; +import { decryptAttendees } from "#shared/db/attendees/pii.ts"; +import { getAttendeesRaw } from "#shared/db/attendees/queries.ts"; +import { getDb } from "#shared/db/client.ts"; +import { getListingDayPrices } from "#shared/db/listing-prices.ts"; +import { getAllListings } from "#shared/db/listings/records.ts"; +import { settings } from "#shared/db/settings.ts"; +import { DEMO_EMAILS, DEMO_NAMES } from "#shared/demo/samples.ts"; +import { + createSeeds, + DEMO_UNIT_PRICES, + SEED_MAX_ATTENDEES, +} from "#shared/seeds.ts"; +import { getTestPrivateKey } from "#test-utils/crypto.ts"; +import { describeWithEnv } from "#test-utils/db.ts"; + +describeWithEnv("seeds", { db: true }, () => { + test("caps a seeded listing's attendees at a hundred thousand", () => { + expect(SEED_MAX_ATTENDEES).toBe(100_000); + }); + + test("the demo price set is exactly the seven sample amounts", () => { + expect(DEMO_UNIT_PRICES).toEqual([500, 1000, 1500, 2000, 2500, 3000, 5000]); + }); + + test("reports exactly what it created", async () => { + const result = await createSeeds(3, 2); + expect(result).toEqual({ attendeesCreated: 6, listingsCreated: 3 }); + expect((await getAllListings()).length).toBe(3); + }); + + test("a listing with no attendees seeds cleanly with zero capacity", async () => { + const result = await createSeeds(1, 0); + expect(result).toEqual({ attendeesCreated: 0, listingsCreated: 1 }); + const [listing] = await getAllListings(); + expect(listing!.max_attendees).toBe(0); + }); + + test("every other listing is paid from the demo prices, the rest are free", async () => { + await createSeeds(4, 0); + const prices = (await getAllListings()) + .toSorted((a, b) => a.id - b.id) + .map((listing) => listing.unit_price); + expect(DEMO_UNIT_PRICES).toContain(prices[0]); + expect(prices[1]).toBe(0); + expect(DEMO_UNIT_PRICES).toContain(prices[2]); + expect(prices[3]).toBe(0); + }); + + test("only the first listing is customisable, with 1/2/3-day demo prices", async () => { + await createSeeds(2, 0); + const listings = (await getAllListings()).toSorted((a, b) => a.id - b.id); + expect(listings[0]!.customisable_days).toBe(true); + expect(listings[1]!.customisable_days).toBe(false); + + // The tiers derive from the base price: 1 day at base, 2 days at 1.8x, + // 3 days at 2.5x, rounded to whole minor units. + const base = listings[0]!.unit_price; + const dayPrices = await getListingDayPrices(listings[0]!.id); + expect(dayPrices).toEqual({ + 1: base, + 2: Math.round(base * 1.8), + 3: Math.round(base * 2.5), + }); + // The projection on the listing row agrees with the stored rows. + expect(listings[0]!.day_prices).toEqual(dayPrices); + }); + + test("capacity equals the booked quantities, which vary between 1 and 4", async () => { + // One listing with a large draw, so a quantity outside 1-4 (or a die that + // stopped varying) cannot hide. + await createSeeds(1, 120); + const [listing] = await getAllListings(); + const attendees = await getAttendeesRaw(listing!.id); + expect(attendees.length).toBe(120); + + const quantities = attendees.map((attendee) => attendee.quantity); + for (const quantity of quantities) { + expect(quantity).toBeGreaterThanOrEqual(1); + expect(quantity).toBeLessThanOrEqual(4); + } + expect(new Set(quantities).size).toBeGreaterThan(1); + expect(listing!.max_attendees).toBe(sum(quantities)); + }); + + test("a paid booking carries its price and a seed payment id", async () => { + // One listing seeds the always-priced first demo listing. + await createSeeds(1, 1); + const [listing] = await getAllListings(); + const raw = await getAttendeesRaw(listing!.id); + const [attendee] = await decryptAttendees(raw, await getTestPrivateKey()); + + expect(DEMO_NAMES).toContain(attendee!.name); + expect(DEMO_EMAILS).toContain(attendee!.email); + // The seed payment id embeds the booking's worth: unit price x quantity. + const worth = listing!.unit_price * raw[0]!.quantity; + expect(attendee!.payment_id).toBe( + `seed_${listing!.id}_${raw[0]!.quantity}_${worth}`, + ); + }); + + test("a free booking has no payment id", async () => { + // Listing 2 (index 1) is free, so its booking must not invent a payment. + await createSeeds(2, 1); + const free = (await getAllListings()).find( + (listing) => listing.unit_price === 0, + ); + const raw = await getAttendeesRaw(free!.id); + const [attendee] = await decryptAttendees(raw, await getTestPrivateKey()); + expect(attendee!.payment_id).toBe(""); + }); + + test("each seeded listing gets its own slug", async () => { + await createSeeds(3, 0); + const rows = await getDb().execute("SELECT slug_index FROM listings"); + const indexes = rows.rows.map((row) => row.slug_index); + expect(new Set(indexes).size).toBe(3); + }); + + test("throws when the public key is not configured", async () => { + await getDb().execute("DELETE FROM settings WHERE key = 'public_key'"); + settings.invalidateCache(); + + await expect(createSeeds(1, 0)).rejects.toThrow( + "Public key not configured", + ); + }); +}); From 9523cce477c8953ceb77021da56af6cbba596be3 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 18 Aug 2026 02:55:50 +0000 Subject: [PATCH 2/7] Fix the lint and dead-export CI failures, and bound two more loops MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI caught two things on the suites commit: an unused import, and the DEMO_UNIT_PRICES export that only tests used — the dead-export check is right that a test-only export is a smell. The demo prices are now deterministic instead: paid listings walk the sample prices in order, so every tier shows up in a big enough demo set and the suite pins the exact sequence through behavior, with no export at all. Two more freeze-capable loops also surfaced. Both had multi-line for headers the sweep's line-based search missed: - der.ts unsignedBytes spun forever under `> → <=` (encoding zero never ends) — the sweep verification run sat on it for an hour. It now recurses on a 256-fold shrink, so every mutant of it ends fast, a stack overflow at worst. - bunny-cdn.ts's certificate retry loop spun under `attempt++ → attempt--` with a never-ok stub. It now walks a bounded range and breaks on success. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Dpnsvq2v2q1DEfWsWQhQA1 --- src/shared/bunny-cdn.ts | 8 +++----- src/shared/crypto/der.ts | 15 ++++----------- src/shared/seeds.ts | 10 ++++++---- test/shared/seeds.test.ts | 24 ++++++++---------------- 4 files changed, 21 insertions(+), 36 deletions(-) diff --git a/src/shared/bunny-cdn.ts b/src/shared/bunny-cdn.ts index 6b5c5cc94b..bb0800192b 100644 --- a/src/shared/bunny-cdn.ts +++ b/src/shared/bunny-cdn.ts @@ -5,6 +5,7 @@ * The pull zone is discovered via the Edge Script API, not request hostname. */ +import { range } from "#fp"; import { getBunnyApiKey, getBunnyDnsSubdomainSuffix, @@ -373,11 +374,8 @@ const registerBunnySubdomainImpl = async ( // 3. Register hostname with pull zone (add hostname + SSL) // Retry to allow DNS propagation after CNAME record creation. let cdnResult = await bunnyCdnApi.validateCustomDomain(fullDomain); - for ( - let attempt = 0; - attempt < CERT_RETRY_COUNT && !cdnResult.ok; - attempt++ - ) { + for (const attempt of range(0, CERT_RETRY_COUNT)) { + if (cdnResult.ok) break; await bunnyCdnApi.delay(certRetryDelay(attempt)); cdnResult = await bunnyCdnApi.validateCustomDomain(fullDomain); } diff --git a/src/shared/crypto/der.ts b/src/shared/crypto/der.ts index 4c4d83be15..c9271128df 100644 --- a/src/shared/crypto/der.ts +++ b/src/shared/crypto/der.ts @@ -23,17 +23,10 @@ export const bytesEqual = (left: Uint8Array, right: Uint8Array): boolean => left.length === right.length && left.every((byte, index) => byte === right[index]); -const unsignedBytes = (value: number): number[] => { - const bytes: number[] = []; - for ( - let remaining = value; - remaining > 0; - remaining = Math.floor(remaining / 256) - ) { - bytes.unshift(remaining & 0xff); - } - return bytes; -}; +/** Big-endian bytes of a non-negative whole number; zero is no bytes. Each + * step shrinks the value 256-fold, so the recursion always bottoms out. */ +const unsignedBytes = (value: number): number[] => + value <= 0 ? [] : [...unsignedBytes(Math.floor(value / 256)), value & 0xff]; const encodeLength = (length: number): Uint8Array => { // Values below 128 use one byte. Otherwise bit 7 marks how many diff --git a/src/shared/seeds.ts b/src/shared/seeds.ts index 89ed34a8ae..3e8f6707fb 100644 --- a/src/shared/seeds.ts +++ b/src/shared/seeds.ts @@ -37,9 +37,8 @@ export const SEED_MAX_ATTENDEES = 100_000; /** Pick a random ticket quantity (1-4) */ const randomQuantity = (): number => 1 + Math.floor(Math.random() * 4); -/** Sample unit prices in minor units (e.g. pence/cents) for paid listings. - * Exported so the seeds suite can pin the exact set a paid listing draws from. */ -export const DEMO_UNIT_PRICES = [500, 1000, 1500, 2000, 2500, 3000, 5000]; +/** Sample unit prices in minor units (e.g. pence/cents) for paid listings */ +const DEMO_UNIT_PRICES = [500, 1000, 1500, 2000, 2500, 3000, 5000]; /** Generate slugs that are unique within the batch */ const generateUniqueSlugs = async (count: number): Promise => { @@ -188,7 +187,10 @@ export const createSeeds = async ( index: i, quantities, slug: slugs[i]!, - unitPrice: i % 2 === 0 ? randomChoice(DEMO_UNIT_PRICES) : 0, + // Paid listings walk the sample prices in order, so a big enough demo + // set shows every tier and a test can name each one. + unitPrice: + i % 2 === 0 ? DEMO_UNIT_PRICES[(i / 2) % DEMO_UNIT_PRICES.length]! : 0, }; }); diff --git a/test/shared/seeds.test.ts b/test/shared/seeds.test.ts index fd023b64ad..e58ccb7012 100644 --- a/test/shared/seeds.test.ts +++ b/test/shared/seeds.test.ts @@ -1,5 +1,5 @@ import { expect } from "@std/expect"; -import { describe, it as test } from "@std/testing/bdd"; +import { it as test } from "@std/testing/bdd"; import { sum } from "#fp"; import { decryptAttendees } from "#shared/db/attendees/pii.ts"; import { getAttendeesRaw } from "#shared/db/attendees/queries.ts"; @@ -8,11 +8,7 @@ import { getListingDayPrices } from "#shared/db/listing-prices.ts"; import { getAllListings } from "#shared/db/listings/records.ts"; import { settings } from "#shared/db/settings.ts"; import { DEMO_EMAILS, DEMO_NAMES } from "#shared/demo/samples.ts"; -import { - createSeeds, - DEMO_UNIT_PRICES, - SEED_MAX_ATTENDEES, -} from "#shared/seeds.ts"; +import { createSeeds, SEED_MAX_ATTENDEES } from "#shared/seeds.ts"; import { getTestPrivateKey } from "#test-utils/crypto.ts"; import { describeWithEnv } from "#test-utils/db.ts"; @@ -21,10 +17,6 @@ describeWithEnv("seeds", { db: true }, () => { expect(SEED_MAX_ATTENDEES).toBe(100_000); }); - test("the demo price set is exactly the seven sample amounts", () => { - expect(DEMO_UNIT_PRICES).toEqual([500, 1000, 1500, 2000, 2500, 3000, 5000]); - }); - test("reports exactly what it created", async () => { const result = await createSeeds(3, 2); expect(result).toEqual({ attendeesCreated: 6, listingsCreated: 3 }); @@ -38,15 +30,15 @@ describeWithEnv("seeds", { db: true }, () => { expect(listing!.max_attendees).toBe(0); }); - test("every other listing is paid from the demo prices, the rest are free", async () => { - await createSeeds(4, 0); + test("every other listing is paid, walking the sample prices in order", async () => { + // Enough listings to walk the whole price set and wrap back around. + await createSeeds(16, 0); const prices = (await getAllListings()) .toSorted((a, b) => a.id - b.id) .map((listing) => listing.unit_price); - expect(DEMO_UNIT_PRICES).toContain(prices[0]); - expect(prices[1]).toBe(0); - expect(DEMO_UNIT_PRICES).toContain(prices[2]); - expect(prices[3]).toBe(0); + expect(prices).toEqual([ + 500, 0, 1000, 0, 1500, 0, 2000, 0, 2500, 0, 3000, 0, 5000, 0, 500, 0, + ]); }); test("only the first listing is customisable, with 1/2/3-day demo prices", async () => { From 5f0528533d25a8fe01a3565d55daaa94eeb42e74 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 18 Aug 2026 02:58:46 +0000 Subject: [PATCH 3/7] Reject fractional and non-finite group ids in parseGroupIds Number.isSafeInteger keeps only whole positive ids, and the group-links test query now aliases its table per the SQL style rule. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01Dpnsvq2v2q1DEfWsWQhQA1 --- src/features/admin/listings-form.ts | 2 +- test/features/admin/listings-form.test.ts | 8 ++++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/src/features/admin/listings-form.ts b/src/features/admin/listings-form.ts index 9bfa3f657a..a816baec44 100644 --- a/src/features/admin/listings-form.ts +++ b/src/features/admin/listings-form.ts @@ -95,7 +95,7 @@ export const parseGroupIds = (form: FormParams): number[] => form .getAll("group_ids") .map(Number) - .filter((n) => n > 0); + .filter((n) => Number.isSafeInteger(n) && n > 0); /** * Read the per-day-count price inputs (`day_price_1`, `day_price_2`, …) from diff --git a/test/features/admin/listings-form.test.ts b/test/features/admin/listings-form.test.ts index 4c4b8aaeb0..fbf067ea66 100644 --- a/test/features/admin/listings-form.test.ts +++ b/test/features/admin/listings-form.test.ts @@ -47,7 +47,9 @@ const updateListing = async ( describeWithEnv("listings form", { db: true }, () => { describe("parseGroupIds", () => { test("keeps only positive whole group ids", () => { - const form = testFormParams({ group_ids: ["3", "0", "-2", "abc", "7"] }); + const form = testFormParams({ + group_ids: ["3", "0", "-2", "abc", "3.5", "Infinity", "7"], + }); expect(parseGroupIds(form)).toEqual([3, 7]); }); @@ -151,7 +153,9 @@ describeWithEnv("listings form", { db: true }, () => { }); const links = await getDb().execute({ args: [row.id], - sql: "SELECT group_id FROM group_listings WHERE listing_id = ?", + sql: + "SELECT groupListing.group_id FROM group_listings AS groupListing " + + "WHERE groupListing.listing_id = ?", }); expect(links.rows.map((r) => r.group_id)).toEqual([group.id]); }); From b918e449ea3bcd48e61f39b32ba75a1968633be4 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 18 Aug 2026 03:47:23 +0000 Subject: [PATCH 4/7] Close the mutation survivors in the seeds and listing-form suites The honest mutation runner found 28 survivors across the two files this branch adds suites for. Most were plain assertion gaps: the seeded listing's name, description, place, booking limits and blank links were never checked, and the listing form never proved a single chosen day stays one day, that a cleared price stores a real zero, that demo mode drops the webhook address, that the use-defaults tick is kept, that a duplicate copies its source's attribute choices, or that a listing with no closing date stores nothing at all. The seed slug helper kept a Set beside the list it was already building; the list is now the only record of what has been used. Seven mutants no input can distinguish are recorded with their proofs, and two stale der.ts records left by the earlier rewrite are removed. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01Dpnsvq2v2q1DEfWsWQhQA1 --- .../mutation/equivalent-mutants/features.txt | 9 ++ .../equivalent-mutants/shared-a-l.txt | 6 +- .../equivalent-mutants/shared-m-z.txt | 4 + src/shared/seeds.ts | 12 +-- test/features/admin/listings-form.test.ts | 84 +++++++++++++++++++ test/shared/seeds.test.ts | 46 +++++++++- 6 files changed, 150 insertions(+), 11 deletions(-) diff --git a/scripts/mutation/equivalent-mutants/features.txt b/scripts/mutation/equivalent-mutants/features.txt index 35e744830b..cb35957834 100644 --- a/scripts/mutation/equivalent-mutants/features.txt +++ b/scripts/mutation/equivalent-mutants/features.txt @@ -171,3 +171,12 @@ src/features/public/order.ts::loadOrderPools~15mruqs 1 → 0 # every listing' src/features/public/order.ts::bookingUrlFor.chosen.kind~17n3p4g → "mutated" # a selection key is always built as kind:id by listingOptionKey/packageOptionKey, so the kind is never missing src/features/public/order.ts::bookingUrlFor.chosen.rawId~1xqr8m1 → "mutated" # the same key always carries its id, so the id half is never missing src/features/public/order.ts::bookingUrlFor.chosen.prefill~1vgmybj 1 → 0 # buildTicketListing sets maxPurchasable to 0 for a sold-out or closed listing and to at least 1 otherwise (max_quantity is at least 1), so the two thresholds never disagree once the checks beside them have passed + +# Listing form (listings-form.ts) — a redundant day-price row the parser drops, +# a default the field's own bounds make unreachable, error text no surface can +# show, and a fallback whose left side is never falsy-but-present. +src/features/admin/listings-form.ts::parseDayPricesFromForm~11szepb 1 → 0 # reading day_price_0 as well changes nothing: parseDayPrices keeps only whole day counts of 1 or more +src/features/admin/listings-form.ts::extractCommonFields.durationDays~1nsjfgg ?? → || # duration_days is a number field with min 1 whose validate rejects anything below 1, so the value here is a positive number or absent +src/features/admin/listings-form.ts::extractCommonFields.closesAt~1ox85dk closes_at → "" # the label only appears in normalizeDatetime's throw, and the field's validateDatetime already refused every value localToUtc would reject +src/features/admin/listings-form.ts::extractCommonFields.date~1ogjyd0 date → "" # same: the date field is validated by the same parser before toInput runs, so the throw naming it is unreachable +src/features/admin/listings-form.ts::listingValidate~19dmftv ?? → || # validateDayPricesFromForm returns null or one fixed non-empty message, so it is never falsy-but-present diff --git a/scripts/mutation/equivalent-mutants/shared-a-l.txt b/scripts/mutation/equivalent-mutants/shared-a-l.txt index c3d50c946a..9f9500419b 100644 --- a/scripts/mutation/equivalent-mutants/shared-a-l.txt +++ b/scripts/mutation/equivalent-mutants/shared-a-l.txt @@ -178,12 +178,10 @@ src/shared/band-name-generator.ts::fixArticles~1rw9fca A $1 → "A $1 mutated" # parsePositiveIntId's strict decimal-digit schema. src/shared/logistics-filter.ts::parseAgentFilter.n~03f157n → "mutated" # parsePositiveIntId's schema rejects any non-digit string identically; the "" fallback and any mutated string both parse to null -# crypto/der.ts — these values are written into Uint8Array elements. Adding or +# crypto/der.ts — this value is written into a Uint8Array element. Adding or # subtracting 128 produces the same low eight bits, and Uint8Array discards all -# higher bits. The base-128 continuation byte is therefore identical either way. +# higher bits. src/shared/crypto/der.ts::encodeLength~0ikcwcz 128 → -128 # DER long-length marker: 128+n and -128+n have identical low eight bits -src/shared/crypto/der.ts::encodeBase128~1em6brp + → - # base-128 continuation: low7+128 and low7-128 have identical low eight bits -src/shared/crypto/der.ts::encodeBase128~1em6brp 128 → -128 # base-128 continuation marker is unchanged after Uint8Array conversion # dates.ts — provably-equivalent survivors from the whole-file run over the # dates suites. Each is unobservable through every export that reaches the line. diff --git a/scripts/mutation/equivalent-mutants/shared-m-z.txt b/scripts/mutation/equivalent-mutants/shared-m-z.txt index f7f7236b9a..2a5ceb55e1 100644 --- a/scripts/mutation/equivalent-mutants/shared-m-z.txt +++ b/scripts/mutation/equivalent-mutants/shared-m-z.txt @@ -261,3 +261,7 @@ src/shared/schema-atlas/types.ts::atlasState.facts~0j1zyry ?? → || # extra.f # data), so no caller, page, or log can ever see this string — no input # distinguishes it from "". src/shared/payment/row-state.ts::SortedAttendeeIdsSchema~1d17kt9 Refund claim attendee ids must be sorted and unique → "" # issue text is discarded by the stored-JSON wrapper; unobservable through any surface + +# seeds.ts — two thresholds no seeded value can fall between. +src/shared/seeds.ts::prepareAttendee.paymentId~01ncynu 0 → 1 # a seeded listing's unit price is 0 or one of the demo prices (500 and up), so no listing sits between the two thresholds +src/shared/seeds.ts::createSeeds~0ghuowu 0 → 1 # the customisable listing always yields a delete plus one multi-row insert, so the list holds 0 or 2 statements, never 1 diff --git a/src/shared/seeds.ts b/src/shared/seeds.ts index 3e8f6707fb..3ca7193678 100644 --- a/src/shared/seeds.ts +++ b/src/shared/seeds.ts @@ -40,16 +40,16 @@ const randomQuantity = (): number => 1 + Math.floor(Math.random() * 4); /** Sample unit prices in minor units (e.g. pence/cents) for paid listings */ const DEMO_UNIT_PRICES = [500, 1000, 1500, 2000, 2500, 3000, 5000]; -/** Generate slugs that are unique within the batch */ +/** Generate slugs that are unique within the batch. The slugs made so far are + * the only "already taken" list — nothing is in the database yet. */ const generateUniqueSlugs = async (count: number): Promise => { - const usedSlugs = new Set(); const results: SlugWithIndex[] = []; for (const _slot of range(0, count)) { - const result = await generateUniqueSlug(hmacHash, (slug) => - Promise.resolve(usedSlugs.has(slug)), + results.push( + await generateUniqueSlug(hmacHash, (slug) => + Promise.resolve(results.some((made) => made.slug === slug)), + ), ); - usedSlugs.add(result.slug); - results.push(result); } return results; }; diff --git a/test/features/admin/listings-form.test.ts b/test/features/admin/listings-form.test.ts index fbf067ea66..e34f1954dd 100644 --- a/test/features/admin/listings-form.test.ts +++ b/test/features/admin/listings-form.test.ts @@ -7,11 +7,17 @@ import { parseGroupIds, } from "#routes/admin/listings-form.ts"; import { VALID_DAY_NAMES } from "#shared/day-names.ts"; +import { listingAttributeOptions } from "#shared/db/attributes.ts"; import { getDb } from "#shared/db/client.ts"; import { getListingDayPrices } from "#shared/db/listing-prices.ts"; import { computeSlugIndex } from "#shared/db/listings/table.ts"; +import { setDemoModeForTest } from "#shared/demo/mode.ts"; import type { Listing } from "#shared/types.ts"; import { describeWithEnv } from "#test-utils/db.ts"; +import { + assignTestAttributeOptions, + createTestAttributeWithOptions, +} from "#test-utils/db-helpers/attributes.ts"; import { createTestGroup } from "#test-utils/db-helpers/groups.ts"; import { type TestFormValues, @@ -118,6 +124,76 @@ describeWithEnv("listings form", { db: true }, () => { expect(row.bookable_days).toEqual(["Monday", "Thursday"]); }); + test("a single chosen day stays that one day", async () => { + const row = await createListing({ bookable_days: ["Friday"] }); + expect(row.bookable_days).toEqual(["Friday"]); + }); + + test("an impossible date is refused before anything is saved", async () => { + const form = listingForm({ date_date: "2026-02-30", date_time: "10:00" }); + const result = await buildCreateListingResource(form).create(form); + expect(result).toEqual({ + error: "Please enter a valid date and time", + ok: false, + }); + }); + + test("a listing with no closing date stores nothing in that column", async () => { + const row = await createListing(); + const stored = await getDb().execute({ + args: [row.id], + sql: + "SELECT listing.closes_at FROM listings AS listing " + + "WHERE listing.id = ?", + }); + expect(stored.rows[0]!.closes_at).toBeNull(); + }); + + test("the use-defaults tick is saved", async () => { + const on = await createListing({ use_defaults: "1" }); + expect(on.use_defaults).toBe(true); + + const off = await createListing({ name: "Own values" }); + expect(off.use_defaults).toBe(false); + }); + + test("demo mode drops the webhook address, normal mode keeps it", async () => { + const hook = "https://example.com/hook"; + const real = await createListing({ webhook_url: hook }); + expect(real.webhook_url).toBe(hook); + + setDemoModeForTest(true); + try { + const demo = await createListing({ + name: "Demo listing", + webhook_url: hook, + }); + expect(demo.webhook_url).toBe(""); + } finally { + setDemoModeForTest(false); + } + }); + + test("duplicating a listing copies the source's attribute choices", async () => { + const attribute = await createTestAttributeWithOptions("Size", [ + "Small", + "Large", + ]); + const source = await createListing({ name: "Source" }); + await assignTestAttributeOptions(source.id, attribute.options); + + const copy = await createListing({ + duplicated_from: String(source.id), + name: "Copy", + }); + expect(await listingAttributeOptions.getIds(copy.id)).toEqual( + attribute.options.map((option) => option.id), + ); + + const fresh = await createListing({ name: "Fresh" }); + expect(await listingAttributeOptions.getIds(fresh.id)).toEqual([]); + }); + test("day prices are read for days one up to the duration only", async () => { const row = await createListing({ day_price_1: "10", @@ -187,6 +263,14 @@ describeWithEnv("listings form", { db: true }, () => { expect(row.slug_index).toBe(await computeSlugIndex("new-slug")); }); + test("clearing the price on an update stores a real zero", async () => { + const created = await createListing({ unit_price: "12.34" }); + expect(created.unit_price).toBe(1234); + + const row = await updateListing(created.id, { unit_price: "0" }); + expect(row.unit_price).toBe(0); + }); + test("a daily listing keeps an emptied day selection empty", async () => { // A create with no days ticked opens every day by default... const created = await createListing({ listing_type: "daily" }); diff --git a/test/shared/seeds.test.ts b/test/shared/seeds.test.ts index e58ccb7012..4f063cc48a 100644 --- a/test/shared/seeds.test.ts +++ b/test/shared/seeds.test.ts @@ -1,13 +1,20 @@ import { expect } from "@std/expect"; import { it as test } from "@std/testing/bdd"; import { sum } from "#fp"; +import { VALID_DAY_NAMES } from "#shared/day-names.ts"; import { decryptAttendees } from "#shared/db/attendees/pii.ts"; import { getAttendeesRaw } from "#shared/db/attendees/queries.ts"; import { getDb } from "#shared/db/client.ts"; import { getListingDayPrices } from "#shared/db/listing-prices.ts"; import { getAllListings } from "#shared/db/listings/records.ts"; import { settings } from "#shared/db/settings.ts"; -import { DEMO_EMAILS, DEMO_NAMES } from "#shared/demo/samples.ts"; +import { + DEMO_EMAILS, + DEMO_LISTING_DESCRIPTIONS, + DEMO_LISTING_LOCATIONS, + DEMO_LISTING_NAMES, + DEMO_NAMES, +} from "#shared/demo/samples.ts"; import { createSeeds, SEED_MAX_ATTENDEES } from "#shared/seeds.ts"; import { getTestPrivateKey } from "#test-utils/crypto.ts"; import { describeWithEnv } from "#test-utils/db.ts"; @@ -41,6 +48,43 @@ describeWithEnv("seeds", { db: true }, () => { ]); }); + test("listings take their name, description and place from the demo lists", async () => { + await createSeeds(3, 0); + const listings = (await getAllListings()).toSorted((a, b) => a.id - b.id); + expect(listings.map((listing) => listing.name)).toEqual( + DEMO_LISTING_NAMES.slice(0, 3), + ); + expect(listings.map((listing) => listing.description)).toEqual( + DEMO_LISTING_DESCRIPTIONS.slice(0, 3), + ); + expect(listings.map((listing) => listing.location)).toEqual( + DEMO_LISTING_LOCATIONS.slice(0, 3), + ); + }); + + test("a seeded listing opens every day with the demo booking limits", async () => { + await createSeeds(1, 0); + const [listing] = await getAllListings(); + expect(listing!.listing_type).toBe("standard"); + expect(listing!.fields).toBe("email"); + expect(listing!.max_quantity).toBe(4); + expect(listing!.minimum_days_before).toBe(1); + expect(listing!.maximum_days_after).toBe(90); + expect(listing!.non_transferable).toBe(false); + expect(listing!.bookable_days).toEqual([...VALID_DAY_NAMES]); + }); + + test("a seeded listing has no dates, no links and no attachment", async () => { + await createSeeds(1, 0); + const [listing] = await getAllListings(); + expect(listing!.date).toBe(""); + expect(listing!.closes_at).toBeNull(); + expect(listing!.thank_you_url).toBe(""); + expect(listing!.webhook_url).toBe(""); + expect(listing!.attachment_url).toBe(""); + expect(listing!.attachment_name).toBe(""); + }); + test("only the first listing is customisable, with 1/2/3-day demo prices", async () => { await createSeeds(2, 0); const listings = (await getAllListings()).toSorted((a, b) => a.id - b.id); From 9dc87c700d452a320e59bc2d57234f30c5919449 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 18 Aug 2026 03:54:09 +0000 Subject: [PATCH 5/7] Pin the stored zero price and alias the seeds slug query Clearing a listing's price must store a real zero, not an absent value: the column is what the base price row is mirrored from. The raw column is now asserted, which is the only place the difference shows. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01Dpnsvq2v2q1DEfWsWQhQA1 --- test/features/admin/listings-form.test.ts | 9 +++++++++ test/shared/seeds.test.ts | 4 +++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/test/features/admin/listings-form.test.ts b/test/features/admin/listings-form.test.ts index e34f1954dd..d7357c841a 100644 --- a/test/features/admin/listings-form.test.ts +++ b/test/features/admin/listings-form.test.ts @@ -269,6 +269,15 @@ describeWithEnv("listings form", { db: true }, () => { const row = await updateListing(created.id, { unit_price: "0" }); expect(row.unit_price).toBe(0); + // A real stored zero, not an absent value: the column is what the base + // price row is mirrored from. + const stored = await getDb().execute({ + args: [row.id], + sql: + "SELECT listing.unit_price FROM listings AS listing " + + "WHERE listing.id = ?", + }); + expect(stored.rows[0]!.unit_price).toBe(0); }); test("a daily listing keeps an emptied day selection empty", async () => { diff --git a/test/shared/seeds.test.ts b/test/shared/seeds.test.ts index 4f063cc48a..acd31ea83f 100644 --- a/test/shared/seeds.test.ts +++ b/test/shared/seeds.test.ts @@ -150,7 +150,9 @@ describeWithEnv("seeds", { db: true }, () => { test("each seeded listing gets its own slug", async () => { await createSeeds(3, 0); - const rows = await getDb().execute("SELECT slug_index FROM listings"); + const rows = await getDb().execute( + "SELECT listing.slug_index FROM listings AS listing", + ); const indexes = rows.rows.map((row) => row.slug_index); expect(new Set(indexes).size).toBe(3); }); From b92f777d2f748530c87484f0cdbdfbfd6c773918 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 18 Aug 2026 04:01:18 +0000 Subject: [PATCH 6/7] Record the unit-price fallback as an equivalent mutant Measured rather than assumed: applying the mutant by hand and running the suite shows the same stored zero. unit_price is a withDefault(() => 0) column, so the undefined the mutant produces is written as the very 0 the nullish fallback would have passed. No input distinguishes them. listings-form.ts now scores 100% (57/57, 6 suppressed). Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01Dpnsvq2v2q1DEfWsWQhQA1 --- scripts/mutation/equivalent-mutants/features.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/mutation/equivalent-mutants/features.txt b/scripts/mutation/equivalent-mutants/features.txt index cb35957834..e511fb6d2a 100644 --- a/scripts/mutation/equivalent-mutants/features.txt +++ b/scripts/mutation/equivalent-mutants/features.txt @@ -177,6 +177,7 @@ src/features/public/order.ts::bookingUrlFor.chosen.prefill~1vgmybj 1 → 0 # # show, and a fallback whose left side is never falsy-but-present. src/features/admin/listings-form.ts::parseDayPricesFromForm~11szepb 1 → 0 # reading day_price_0 as well changes nothing: parseDayPrices keeps only whole day counts of 1 or more src/features/admin/listings-form.ts::extractCommonFields.durationDays~1nsjfgg ?? → || # duration_days is a number field with min 1 whose validate rejects anything below 1, so the value here is a positive number or absent +src/features/admin/listings-form.ts::extractCommonFields.unitPrice~0fxriin ?? → || # the only falsy non-null price is 0, and unit_price is a withDefault(() => 0) column, so the undefined the mutant produces is stored as that same 0 src/features/admin/listings-form.ts::extractCommonFields.closesAt~1ox85dk closes_at → "" # the label only appears in normalizeDatetime's throw, and the field's validateDatetime already refused every value localToUtc would reject src/features/admin/listings-form.ts::extractCommonFields.date~1ogjyd0 date → "" # same: the date field is validated by the same parser before toInput runs, so the throw naming it is unreachable src/features/admin/listings-form.ts::listingValidate~19dmftv ?? → || # validateDayPricesFromForm returns null or one fixed non-empty message, so it is never falsy-but-present From f5eeda7a7408a520d1aa57b57ad2c3d3cbea56d1 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 18 Aug 2026 04:13:47 +0000 Subject: [PATCH 7/7] Say what the attendee ceiling test actually guards The seeds page is where the ceiling is enforced (it clamps to it and offers it as the box's max), and both of that page's tests read the number from the constant, so they would follow it anywhere it moved. Naming the number here is what keeps it still. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01Dpnsvq2v2q1DEfWsWQhQA1 --- scripts/mutation/equivalent-mutants/shared-a-l.txt | 2 +- test/shared/seeds.test.ts | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/scripts/mutation/equivalent-mutants/shared-a-l.txt b/scripts/mutation/equivalent-mutants/shared-a-l.txt index 9f9500419b..e1536564ca 100644 --- a/scripts/mutation/equivalent-mutants/shared-a-l.txt +++ b/scripts/mutation/equivalent-mutants/shared-a-l.txt @@ -25,7 +25,7 @@ src/shared/qr-token.ts::buildQrBookPayload.d~1ywzxp9 ?? → || # input.date?: src/shared/qr-token.ts::buildQrBookPayload.n~1tn44vm ?? → || # input.name?: string, only falsy string "" === fallback "" src/shared/app-forms.ts::createAuthedHandler~1q900po ?? → || # config.auth is an AuthPolicy object when present, so it is always truthy src/shared/site-assignment.ts::renewalDeadlineBaseMs~00cywnq ?? → || # parseReadOnlyFromMs(): number|null, 0 ?? 0 === 0 || 0 -src/shared/site-assignment.ts::assignSitesForEntries.site~101ljxt ?? → || # available[idx]: BuiltSite|undefined, and a BuiltSite object is always truthy +src/shared/site-assignment.ts::assignSitesForEntries.site~0c03jq6 ?? → || # available.pop(): BuiltSite|undefined, and a BuiltSite object is always truthy src/shared/site-assignment.ts::sendSiteAssignmentEmail.config~11vz7t9 ?? → || # getEmailConfig(): EmailConfig|null src/shared/site-assignment.ts::sendSiteAssignmentEmail.replyTo~195nl86 ?? → || # parseEmail(): ValidEmail|null, always truthy or null src/shared/ledger/project.ts::allBalances.add~0gr4ng6 ?? → || # allBalances: Map.get; the only falsy-non-null number is 0, and 0 ?? 0 === 0 || 0 diff --git a/test/shared/seeds.test.ts b/test/shared/seeds.test.ts index acd31ea83f..f74fad6475 100644 --- a/test/shared/seeds.test.ts +++ b/test/shared/seeds.test.ts @@ -20,7 +20,10 @@ import { getTestPrivateKey } from "#test-utils/crypto.ts"; import { describeWithEnv } from "#test-utils/db.ts"; describeWithEnv("seeds", { db: true }, () => { - test("caps a seeded listing's attendees at a hundred thousand", () => { + // The seeds page clamps to this ceiling and offers it as the box's max, so + // both of its own tests read it from here and would follow it if it moved. + // Naming the number is what keeps it from moving unnoticed. + test("the attendee ceiling the seeds page clamps to is a hundred thousand", () => { expect(SEED_MAX_ATTENDEES).toBe(100_000); });