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/scripts/mutation/equivalent-mutants/features.txt b/scripts/mutation/equivalent-mutants/features.txt index 35e744830b..e511fb6d2a 100644 --- a/scripts/mutation/equivalent-mutants/features.txt +++ b/scripts/mutation/equivalent-mutants/features.txt @@ -171,3 +171,13 @@ 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.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 diff --git a/scripts/mutation/equivalent-mutants/shared-a-l.txt b/scripts/mutation/equivalent-mutants/shared-a-l.txt index c3d50c946a..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 @@ -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/features/admin/listings-form.ts b/src/features/admin/listings-form.ts index a45a80151d..a816baec44 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, @@ -94,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 @@ -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/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 c4cb59b2f2..3ca7193678 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"; @@ -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 (let i = 0; i < count; i++) { - const result = await generateUniqueSlug(hmacHash, (slug) => - Promise.resolve(usedSlugs.has(slug)), + for (const _slot of range(0, count)) { + results.push( + await generateUniqueSlug(hmacHash, (slug) => + Promise.resolve(results.some((made) => made.slug === slug)), + ), ); - usedSlugs.add(result.slug); - results.push(result); } return results; }; @@ -187,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, }; }); @@ -232,9 +235,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 +243,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..d7357c841a --- /dev/null +++ b/test/features/admin/listings-form.test.ts @@ -0,0 +1,298 @@ +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 { 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, + 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", "3.5", "Infinity", "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("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", + 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 groupListing.group_id FROM group_listings AS groupListing " + + "WHERE groupListing.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("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); + // 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 () => { + // 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..f74fad6475 --- /dev/null +++ b/test/shared/seeds.test.ts @@ -0,0 +1,171 @@ +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_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"; + +describeWithEnv("seeds", { db: true }, () => { + // 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); + }); + + 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, 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(prices).toEqual([ + 500, 0, 1000, 0, 1500, 0, 2000, 0, 2500, 0, 3000, 0, 5000, 0, 500, 0, + ]); + }); + + 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); + 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 listing.slug_index FROM listings AS listing", + ); + 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", + ); + }); +});