Test inventory
Every automated test behind this site, by name. The list is written by the same production build that deployed this page, so it can’t fall out of date.
- Unit tests
- 516
- 47 files
- End-to-end tests
- 127
- 21 specs
- Security scan
- ZAP
- every pull request
- After each deploy
- Smoke
- the live site
Unit and component tests (516)
Vitest, with Testing Library for components. They run on every pull request and again inside Vercel’s production build, and a single failure stops the deploy — so every test below passed in the build that produced this page. Some are source scans: they read the code itself and fail on a hard-coded brand colour, a domain typed into a page, or image metadata in the bundle.
__tests__/admin-form-fields.test.ts7 tests
- admin forms send every tick-box the server reads › createProductAction: no field is read that its form never sends
- admin forms send every tick-box the server reads › updateProductAction: no field is read that its form never sends
- admin forms send every tick-box the server reads › the Sold tick-box exists on both forms
- admin forms send every tick-box the server reads › never tests a checkbox with !== "off"
- a collector's photo is stored as theirs, not the artist's › re-encodes the file rather than editing it
- a collector's photo is stored as theirs, not the artist's › does not stamp the artist's copyright on it
- a collector's photo is stored as theirs, not the artist's › keeps it in its own folder, apart from the artist's work
__tests__/analytics-exclusion.test.ts21 tests
- analytics ingest exclusion › records an event from a browser that isn't excluded
- analytics ingest exclusion › drops an event from an excluded browser
- analytics ingest exclusion › looks the visitor up by cookie, never by city
- analytics ingest exclusion › still records when the exclusion lookup fails — a real visit must not be lost
- analytics ingest exclusion › drops excluded action events too, not just pageviews
- analytics ingest exclusion › never reaches the lookup for admin paths
- analytics ingest exclusion › returns 204 either way, so the page can't tell it was excluded
- isNonProductionRequest › drops localhost:3000
- isNonProductionRequest › drops localhost
- isNonProductionRequest › drops 127.0.0.1:3000
- isNonProductionRequest › drops [::1]:3000
- isNonProductionRequest › drops app.localhost:3000
- isNonProductionRequest › drops mymac.local
- isNonProductionRequest › drops georgianartist.test
- isNonProductionRequest › counts www.georgianartist.com
- isNonProductionRequest › counts georgianartist.com
- isNonProductionRequest › drops Vercel preview deployments whatever the hostname
- isNonProductionRequest › counts an unrecognised host rather than losing a real visitor
- isNonProductionRequest › counts a request with no Host header at all
- analytics ingest host gate › records a production request
- analytics ingest host gate › drops a localhost request before it reaches the database
__tests__/analytics.test.ts18 tests
- parseUserAgent — device › detects mobile
- parseUserAgent — device › detects tablet
- parseUserAgent — device › defaults to desktop
- parseUserAgent — browser › identifies browsers, preferring specific tokens
- parseUserAgent — os › identifies operating systems
- isBot › flags crawlers and tooling
- isBot › passes real browsers
- pctChange › formats a positive change with leading + and up = true
- pctChange › formats a negative change with the minus sign and up = false
- pctChange › treats no change as neither up nor down (null)
- pctChange › labels a brand-new period 'new' when previous was 0 and current > 0
- pctChange › returns em-dash when both periods are 0 (no signal)
- pctChange › rounds fractional percents to the nearest whole number
- isoDay › returns YYYY-MM-DD in UTC
- fillDailySeries › emits exactly `days` buckets even when input is empty
- fillDailySeries › merges sparse server rows into the dense series
- fillDailySeries › coerces string-y counts coming back from $queryRaw to numbers
- fillDailySeries › handles a 90-day range without gaps
__tests__/AnalyticsTracker.test.tsx4 tests
- AnalyticsTracker › records a pageview for a real page
- AnalyticsTracker › does not record a pageview when the 404 page marked the path
- AnalyticsTracker › still records the next real page after a 404
- AnalyticsTracker › never records admin paths
__tests__/archive-file.test.ts4 tests
- writeArchive › gives two runs on the same day their own files
- writeArchive › throws rather than overwrite, and leaves the first archive intact
- writeArchive › names the file to the second, in a form every filesystem accepts
- writeArchive › creates the directory if it doesn't exist yet
__tests__/artwork-references.test.ts2 tests
- artwork references › no page or component names a catalogue image by path
- artwork references › the gift shop packs carry no thumbnail field
__tests__/availability.test.ts23 tests
- matchesAvailability › includes sold-out work in the main gallery
- matchesAvailability › includes it in a category view too
- matchesAvailability › keeps it out of the __new__ view
- matchesAvailability › keeps it out of the __sale__ view
- matchesAvailability › shows only sold-out work in the archive view
- matchesAvailability › treats a missing flag as available
- soldOutMatches › finds sold-out works a search would otherwise have surfaced
- soldOutMatches › is case- and whitespace-insensitive
- soldOutMatches › never offers available work as an archive suggestion
- soldOutMatches › returns nothing for an empty query
- availabilitySchema › marks sold-out work SoldOut
- availabilitySchema › marks everything else InStock
- availabilitySchema › only ever emits real schema.org URLs
- excludesSoldOut › is true for __new__, a view about buying
- excludesSoldOut › is true for __sale__, a view about buying
- excludesSoldOut › is false for null
- excludesSoldOut › is false for Bruce Peninsula
- excludesSoldOut › is false for __soldout__
- soldOutLast › puts every sold-out work after every work for sale
- soldOutLast › keeps each group in the order the chosen sort left it
- soldOutLast › never lets a sold-out work sit between two for sale
- soldOutLast › treats a missing flag as for sale
- soldOutLast › does not mutate what it was given
__tests__/blob-cleanup.test.ts17 tests
- releaseBlob › deletes a blob nothing else references
- releaseBlob › keeps a blob another product shares — the epping/craigleith case
- releaseBlob › ignores the product being deleted when counting
- releaseBlob › keeps a blob a queued Instagram post still needs
- releaseBlob › keeps a blob a making-of film still needs
- releaseBlob › matches URLs ignoring any query string
- releaseBlob › leaves legacy repo paths alone — they aren't blobs
- releaseBlob › does nothing when there is no URL at all
- releaseBlob › keeps the blob rather than throwing when the delete fails
- countReferences › counts the same URL held in two places
- countReferences › counts zero when only the excluded product holds it
- isBlobUrl › https://x.public.blob.vercel-storage.com/inspiration/bruce-trail-abc123.jpg → true
- isBlobUrl › /paintings/scenic-caves.jpg → false
- isBlobUrl › https://www.georgianartist.com/paintings/x.jpg → false
- isBlobUrl › → false
- a collector's home photo › counts as a reference, so cleanup never deletes it out from under a page
- a collector's home photo › is released once the only painting that shows it lets go
__tests__/booking-reply-to.test.ts5 tests
- booking emails › sends exactly the two the handoff specifies
- booking emails › sets a Reply-To on the notification to James
- booking emails › sets a Reply-To on the autoresponder to the submitter
- booking emails › points the autoresponder's replies at a mailbox that is read
- booking emails › answers the notification back to whoever submitted it
__tests__/booking.test.ts15 tests
- formatBookingEmail › matches the body the handoff specifies, exactly
- formatBookingEmail › keeps a line for every field even when the optional ones are blank
- formatBookingEmail › preserves the order, so the eye lands in the same place every time
- formatBookingEmail › does not mangle multi-line notes
- checkBookingSubmission › passes a normal submission
- checkBookingSubmission › catches a filled honeypot
- checkBookingSubmission › catches a form completed impossibly fast
- checkBookingSubmission › allows a submission right on the threshold
- checkBookingSubmission › never rejects a real person over a missing signal › passes with no elapsedMs at all
- checkBookingSubmission › never rejects a real person over a missing signal › passes with a non-numeric elapsedMs
- checkBookingSubmission › never rejects a real person over a missing signal › passes with NaN
- checkBookingSubmission › never rejects a real person over a missing signal › passes with a negative clock
- checkBookingSubmission › never rejects a real person over a missing signal › passes with no honeypot field
- checkBookingSubmission › never rejects a real person over a missing signal › passes with a whitespace-only honeypot
- checkBookingSubmission › gives a human long enough to actually type
__tests__/BookingClient.test.tsx3 tests
- BookingClient › sends a numeric elapsedMs measured from mount
- BookingClient › sends the honeypot field so the server can read it
- BookingClient › measures from mount, so an immediate submit is still under the bot threshold
__tests__/brand-colour.test.ts5 tests
- brand colour › lib/brand.ts matches --color-brand in globals.css
- brand colour › lib/brand.ts matches --color-brand-hover in globals.css
- brand colour › lib/brand.ts matches --color-brand-tint in globals.css
- brand colour › defines the tokens inside @theme, so Tailwind generates the utilities
- no brand hex literals outside the definitions › every other file uses the token
__tests__/BuyNowButton.test.tsx6 tests
- BuyNowButton › renders a 'Buy Now' button
- BuyNowButton › clicking Buy Now reveals the enquiry form
- BuyNowButton › form shows the product title, size and price
- BuyNowButton › Cancel button hides the form and restores Buy Now
- BuyNowButton › shows confirmation after submitting the enquiry form
- BuyNowButton › posts the order as ids and choices, never the displayed title or price
__tests__/ContactClient.test.tsx4 tests
- ContactClient › renders the contact form
- ContactClient › does not show the confirmation message before submission
- ContactClient › shows a confirmation message after the form is submitted
- ContactClient › hides the form after submission
__tests__/csp.test.ts12 tests
- buildCsp › allows scripts only by nonce, never inline or by host
- buildCsp › keeps eval and blob workers to the admin, for the HEIC converter
- buildCsp › blocks framing, plugins and base-tag hijacking, and reports violations
- buildCsp › names every outside service the pages use
- buildCsp › only upgrades insecure requests when asked (enforcing, over HTTPS)
- buildCsp › starts in report-only mode
- makeNonce › is fresh every time
- parseReports › reads the report-uri shape, keeping only the blocked origin
- parseReports › reads the Reporting API shape
- parseReports › drops reports about other sites' pages
- parseReports › caps how much one request can record
- parseReports › ignores junk
__tests__/data.test.ts83 tests
- products data › has no duplicate IDs
- products data › every product has a valid sizeType
- products data › every product has a non-empty title, image, and positive price
- products data › every product ID is URL-safe (lowercase, hyphens only)
- products data › every product image, mockup and inspiration photo exists on disk
- photographs data › has no duplicate IDs
- photographs data › every photograph has a valid sizeType
- photographs data › every photograph has a non-empty title, image, and positive price
- photographs data › every photograph has positive naturalWidth and naturalHeight
- photographs data › no ID collides with a product ID
- blur placeholders › has one for every catalogue image file
- blur placeholders › alpine-ski-fall stays small
- blur placeholders › beaver-river-access stays small
- blur placeholders › blue-mountain-sunrise-painting stays small
- blur placeholders › blue-mountain-sunset-painting stays small
- blur placeholders › blue-mountain-trail-painting stays small
- blur placeholders › bridal-veil-falls-painting stays small
- blur placeholders › bruce-trail-epping stays small
- blur placeholders › bruce-trail stays small
- blur placeholders › council-beach stays small
- blur placeholders › delphi-point-painting stays small
- blur placeholders › inglis-falls stays small
- blur placeholders › local-farm-painting stays small
- blur placeholders › lora-bay stays small
- blur placeholders › manitoulin-island stays small
- blur placeholders › near-killarney stays small
- blur placeholders › northwinds-beach stays small
- blur placeholders › peasemarsh stays small
- blur placeholders › ravenna-canola-painting stays small
- blur placeholders › scenic-caves stays small
- blur placeholders › singhampton stays small
- blur placeholders › the-grotto-painting stays small
- blur placeholders › top-of-blue-mountain stays small
- blur placeholders › Delphi_Point stays small
- blur placeholders › Ravenna_Canola stays small
- blur placeholders › alpine-ski-fall-photo stays small
- blur placeholders › autumn-canopy stays small
- blur placeholders › beaver-river-access-photo stays small
- blur placeholders › blue-mountain-sunrise stays small
- blur placeholders › blue-mountain-sunset-2 stays small
- blur placeholders › blue-mountain-trail stays small
- blur placeholders › bridal-veil-falls stays small
- blur placeholders › bruce-trail-photo stays small
- blur placeholders › council-beach-photo stays small
- blur placeholders › inglis-falls-photo stays small
- blur placeholders › local-farm stays small
- blur placeholders › lora-bay-photo stays small
- blur placeholders › manitoulin-island-photo stays small
- blur placeholders › near-killarney-photo stays small
- blur placeholders › northwinds-beach-twilight stays small
- blur placeholders › scenic-caves-photo stays small
- blur placeholders › singhampton-photo stays small
- blur placeholders › snowy-owl-in-craigleith stays small
- blur placeholders › the-grotto stays small
- blur placeholders › top-of-blue-mountain-photo stays small
- blur placeholders › carries no embedded metadata
- blur placeholders › keeps the whole file small enough to ship in the bundle
- making-of seed › has films
- making-of seed › alpine-ski-fall names absolute Blob URLs for both video and poster
- making-of seed › beaver-river-access names absolute Blob URLs for both video and poster
- making-of seed › blue-mountain-sunrise names absolute Blob URLs for both video and poster
- making-of seed › blue-mountain-sunset names absolute Blob URLs for both video and poster
- making-of seed › blue-mountain-trail names absolute Blob URLs for both video and poster
- making-of seed › bridal-veil-falls names absolute Blob URLs for both video and poster
- making-of seed › bruce-trail-epping names absolute Blob URLs for both video and poster
- making-of seed › bruce-trail-grey-highlands names absolute Blob URLs for both video and poster
- making-of seed › council-beach names absolute Blob URLs for both video and poster
- making-of seed › delphi-point names absolute Blob URLs for both video and poster
- making-of seed › inglis-falls names absolute Blob URLs for both video and poster
- making-of seed › local-farm-sunflowers names absolute Blob URLs for both video and poster
- making-of seed › lora-bay names absolute Blob URLs for both video and poster
- making-of seed › manitoulin-island names absolute Blob URLs for both video and poster
- making-of seed › near-killarney names absolute Blob URLs for both video and poster
- making-of seed › northwinds-beach names absolute Blob URLs for both video and poster
- making-of seed › peasemarsh names absolute Blob URLs for both video and poster
- making-of seed › ravenna-canola names absolute Blob URLs for both video and poster
- making-of seed › scenic-caves names absolute Blob URLs for both video and poster
- making-of seed › singhampton names absolute Blob URLs for both video and poster
- making-of seed › the-grotto names absolute Blob URLs for both video and poster
- making-of seed › top-of-blue-mountain names absolute Blob URLs for both video and poster
- making-of seed › points every film at a product that exists in the seed
- making-of seed › has no duplicate ids
- making-of seed › gives every film a plausible duration
__tests__/e2e-db-guard.test.ts15 tests
- databaseHost › reads the host out of a connection string
- databaseHost › unwraps a bracketed IPv6 literal
- databaseHost › returns null rather than throwing on nonsense
- isThrowawayDatabase › accepts the local hosts CI and a laptop container use
- isThrowawayDatabase › rejects production
- isThrowawayDatabase › fails closed on anything it cannot vouch for
- isThrowawayDatabase › is not fooled by a production host that merely mentions localhost
- assertThrowawayDatabase › passes for a throwaway database
- assertThrowawayDatabase › throws for production
- assertThrowawayDatabase › throws when DATABASE_URL is unset
- assertThrowawayDatabase › reads nothing from the environment
- assertThrowawayDatabase › lets an explicit override through
- assertThrowawayDatabase › does not treat any other override value as consent
- the refusal message › names the host, the damage, and the way out
- the refusal message › says so plainly when DATABASE_URL is simply missing
__tests__/email-validation.test.ts6 tests
- validateEmail › rejects missing or malformed addresses (no MX lookup needed)
- validateEmail › rejects disposable domains
- validateEmail › rejects Gmail dot-abuse (4+ dots in the local part)
- validateEmail › accepts a well-formed address with a valid MX record
- validateEmail › rejects when the domain has no MX records
- validateEmail › rejects when the MX lookup throws (nonexistent domain)
__tests__/Footer.test.tsx5 tests
- Footer › links to the Instagram account the cron posts to
- Footer › shows the handle as text, not just as a URL
- Footer › opens in a new tab without handing the target window a reference back
- Footer › links the pages kept out of the header menu
- Footer › credits James Portman from 2021, with the current year as the end
__tests__/form-email-abuse.test.ts9 tests
- /api/buy › builds both emails from the catalogue, not from what was posted
- /api/buy › repeats nothing the visitor typed in the confirmation
- /api/buy › refuses an order for a work that isn't in the catalogue, and sends nothing
- /api/buy › sends nothing at all once the IP is over its limit
- /api/buy › still tells the owner when only the confirmation is held back
- /api/contact › repeats nothing the visitor typed in the confirmation
- /api/contact › sends nothing once the IP is over its limit
- /api/book › repeats nothing the visitor typed in the autoresponder
- /api/book › sends nothing once the IP is over its limit
__tests__/form-rate-limit.test.ts5 tests
- limitKey › never contains the raw IP or email address
- limitKey › is stable, so repeat submissions count against the same row
- limitKey › treats an address the same whatever its case or padding
- limitKey › keeps each scope separate
- LIMITS › lets a real customer through: at least two submissions, and a confirmation
__tests__/GalleryGrid.test.tsx4 tests
- GalleryGrid › puts sold-out work in its own section under a heading
- GalleryGrid › renders the work for sale before the sold-out section
- GalleryGrid › leaves the heading out when nothing is sold out
- GalleryGrid › shows a plain grid in the Sold-out filter view
__tests__/gift-shop-sold-out.test.ts4 tests
- gift shop pack builders › offer visible work that is still for sale
- gift shop pack builders › never offer a sold-out work
- getVisibleWorks › leaves out sold-out work when asked for work for sale (the gift shop thumbnails)
- getVisibleWorks › still includes it by default (the share image)
__tests__/health.test.ts18 tests
- transition › null → false gives null (first run, healthy: nothing to say)
- transition › null → true gives failed (first run, broken: say so)
- transition › false → true gives failed (healthy → broken)
- transition › true → true gives null (still broken: already said)
- transition › true → false gives recovered (broken → healthy)
- transition › false → false gives null (still healthy)
- transition › unknown → true gives failed (state unreadable and broken: report rather than risk silence)
- transition › unknown → false gives null (state unreadable but healthy)
- checkPage › passes a 200 that rendered the storefront
- checkPage › fails an error status
- checkPage › fails a 200 that didn't render the storefront
- checkPage › fails when the request itself fails
- checkDatabase › fails an empty catalogue, not only an unreachable one
- /api/cron/health › refuses a request without the cron secret, and checks nothing
- /api/cron/health › is quiet while everything passes
- /api/cron/health › emails once when a page breaks, not on every run after
- /api/cron/health › emails once more on recovery, marked as good news
- /api/cron/health › still emails when the database is down and state can't be recorded
__tests__/home-photo.test.ts6 tests
- prepareHomePhoto › removes GPS from a PNG
- prepareHomePhoto › removes GPS from a JPEG
- prepareHomePhoto › removes GPS from a WEBP
- prepareHomePhoto › always writes a JPEG, whatever arrived
- prepareHomePhoto › turns a sideways phone photo upright before dropping the flag that said so
- prepareHomePhoto › refuses a file it cannot read, rather than publishing it as-is
__tests__/HomePhotoField.test.tsx4 tests
- HomePhotoField › offers an upload and a credit line on a painting with no photo yet
- HomePhotoField › has nothing to remove when there is no photo
- HomePhotoField › shows the existing photo and credit when editing
- HomePhotoField › can remove it in one tick, which hides the preview and the credit
__tests__/image-copyright.test.ts25 tests
- embedCopyright › writes the notice into both EXIF and XMP
- embedCopyright › keeps the rights holder and the credit line as separate claims
- embedCopyright › never touches the pixels
- embedCopyright › keeps APP0/JFIF first, as the format requires
- embedCopyright › preserves segments it doesn't own, like the comment
- embedCopyright › replaces existing EXIF rather than appending a second copy
- embedCopyright › replaces existing XMP rather than appending a second copy
- embedCopyright › preserves the ICC colour profile the browser canvas attaches
- embedCopyright › puts our segments between APP0 and APP2, the order the format expects
- embedCopyright › is idempotent — re-running produces a byte-identical file
- embedCopyright › rolls the end year over on its own
- embedCopyright › never costs someone their upload › returns an empty buffer unchanged instead of throwing
- embedCopyright › never costs someone their upload › returns a PNG unchanged instead of throwing
- embedCopyright › never costs someone their upload › returns plain text unchanged instead of throwing
- embedCopyright › never costs someone their upload › returns a truncated JPEG unchanged instead of throwing
- embedCopyright › never costs someone their upload › returns a JPEG with a bad segment length unchanged instead of throwing
- embedCopyright › never costs someone their upload › returns a JPEG that never reaches its scan unchanged instead of throwing
- stripMetadata › removes EXIF, XMP and ICC but keeps the pixels
- stripMetadata › drops the comment segment too
- stripMetadata › keeps the tables and headers needed to decode
- stripMetadata › makes output independent of metadata — the whole point
- stripMetadata › is idempotent
- stripMetadata › returns an empty buffer unchanged
- stripMetadata › returns a PNG unchanged
- stripMetadata › returns a truncated JPEG unchanged
__tests__/instagram-caption-context.test.ts28 tests
- buildProductContext price range › quotes landscape from one scale, not sale-floor to regular-ceiling
- buildProductContext price range › quotes square from one scale, not sale-floor to regular-ceiling
- buildProductContext price range › quotes panoramic from one scale, not sale-floor to regular-ceiling
- buildProductContext price range › quotes ultrawide from one scale, not sale-floor to regular-ceiling
- buildProductContext price range › quotes portrait from one scale, not sale-floor to regular-ceiling
- buildProductContext price range › quotes diptych from one scale, not sale-floor to regular-ceiling
- buildProductContext price range › never quotes a landscape regular price as the ceiling
- buildProductContext price range › never quotes a square regular price as the ceiling
- buildProductContext price range › never quotes a panoramic regular price as the ceiling
- buildProductContext price range › never quotes a ultrawide regular price as the ceiling
- buildProductContext price range › never quotes a portrait regular price as the ceiling
- buildProductContext price range › never quotes a diptych regular price as the ceiling
- buildProductContext price range › matches what the ultrawide detail page shows a buyer
- buildProductContext price range › falls back to the product's own price when there is only one size
- captionPriceIsStale › catches the exact bug that shipped — sale floor against regular ceiling
- captionPriceIsStale › passes a caption that quotes the current range
- captionPriceIsStale › reads CA$119–$299 as the same claim, however Claude punctuated it
- captionPriceIsStale › reads CA$119-299 as the same claim, however Claude punctuated it
- captionPriceIsStale › reads CA$119 – CA$299 as the same claim, however Claude punctuated it
- captionPriceIsStale › reads CA$119—299 as the same claim, however Claude punctuated it
- captionPriceIsStale › leaves a caption that names no range alone
- captionPriceIsStale › does not treat a single-size work's flat price as a stale range
- captionPriceIsStale › flags the old range for every landscape work in the queue
- captionPriceIsStale › flags the old range for every square work in the queue
- captionPriceIsStale › flags the old range for every panoramic work in the queue
- captionPriceIsStale › flags the old range for every ultrawide work in the queue
- captionPriceIsStale › flags the old range for every portrait work in the queue
- captionPriceIsStale › flags the old range for every diptych work in the queue
__tests__/json-ld.test.ts4 tests
- safeJsonLd › neutralizes a </script> breakout in string values
- safeJsonLd › round-trips identically through JSON.parse
- safeJsonLd › escapes U+2028/U+2029 line separators
- safeJsonLd › leaves ordinary product data unchanged
__tests__/legacy-urls.test.ts25 tests
- resolveLegacySlug › matches lora-bay by id
- resolveLegacySlug › matches northwinds-beach by id
- resolveLegacySlug › matches manitoulin-island by id
- resolveLegacySlug › matches blue-mountain-sunset by title, where the id alone would not
- resolveLegacySlug › matches alpine-ski-club-fall by title, where the id alone would not
- resolveLegacySlug › matches beaver-river-access-point-2 by title, where the id alone would not
- resolveLegacySlug › matches bruce-trail-in-the-grey-highlands by title, where the id alone would not
- resolveLegacySlug › follows a rename only a human could know about
- resolveLegacySlug › refuses to guess › lone-tree keeps its 404 rather than landing on the wrong painting
- resolveLegacySlug › refuses to guess › lake-huron-shore keeps its 404 rather than landing on the wrong painting
- resolveLegacySlug › refuses to guess › after-the-storm keeps its 404 rather than landing on the wrong painting
- resolveLegacySlug › refuses to guess › blue-mountain-in-distance keeps its 404 rather than landing on the wrong painting
- resolveLegacySlug › refuses to guess › nottawasaga-winter keeps its 404 rather than landing on the wrong painting
- resolveLegacySlug › refuses to guess › ravenna-sunset keeps its 404 rather than landing on the wrong painting
- resolveLegacySlug › refuses to guess › tobermory keeps its 404 rather than landing on the wrong painting
- resolveLegacySlug › refuses to guess › algonquin-park-meadow keeps its 404 rather than landing on the wrong painting
- resolveLegacySlug › refuses to guess › milk-dip-cup-92wf6-gc7e4-ctxtn-mk6 keeps its 404 rather than landing on the wrong painting
- resolveLegacySlug › refuses to guess › country-feast-set-3nybt-gnw4d-4jmt8 keeps its 404 rather than landing on the wrong painting
- resolveLegacySlug › refuses to guess › aluminum-beaver-river-summary keeps its 404 rather than landing on the wrong painting
- resolveLegacySlug › never redirects to a hidden work — that would just be a slower 404
- resolveLegacySlug › sends photographs to /photographs and paintings to /products
- resolveLegacySlug › handles junk input ()
- resolveLegacySlug › handles junk input (---)
- resolveLegacySlug › handles junk input (aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa)
- resolveLegacySlug › resolves nothing against an empty catalogue rather than throwing
__tests__/MakingOfVideo.test.tsx12 tests
- MakingOfVideo › puts no <video> on the page until the film is asked for
- MakingOfVideo › serves the still through the image optimiser, not straight off Blob
- MakingOfVideo › reserves the film's real shape so the page doesn't jump
- MakingOfVideo › falls back to 4:3 for a film nobody has measured
- MakingOfVideo › mounts the player, already playing, once the facade is clicked
- MakingOfVideo › routes the player's own poster through the optimiser
- MakingOfVideo › still links to the file, so it does something with JavaScript off
- MakingOfVideo › leaves modified clicks to the browser
- MakingOfVideo › shows the heading, runtime and description
- MakingOfVideo › formats a runtime over a minute as m:ss
- MakingOfVideo › omits the heading block when heading is null
- MakingOfVideo › survives a film with no poster, description or runtime
__tests__/media-fingerprint.test.ts12 tests
- mediaHasChanged › blocks when the media genuinely changed
- mediaHasChanged › allows when it is byte-identical
- mediaHasChanged › allows when there is no baseline — a row queued before fingerprints existed
- mediaHasChanged › allows when the current value cannot be read — a HEAD failure must not block a post
- mediaHasChanged › allows when neither is known
- mediaHasChanged › notices a size change even when the etag is missing
- fetchMediaFingerprint › combines etag and length
- fetchMediaFingerprint › works from length alone when no etag is served
- fetchMediaFingerprint › returns null when the server offers neither header
- fetchMediaFingerprint › returns null on a non-OK response rather than throwing
- fetchMediaFingerprint › returns null when the request throws — never propagates into the cron
- fetchMediaFingerprint › uses HEAD, so it never downloads a 15MB reel to check it
__tests__/name-validation.test.ts5 tests
- validateName › rejects empty / too-short / non-string names
- validateName › accepts legitimate single-word names (mononyms / first-name-only)
- validateName › accepts normal full names with spaces
- validateName › rejects long single-word gibberish (no space, >20 chars)
- validateName › allows a long name as long as it contains a space
__tests__/Navigation.test.tsx4 tests
- Navigation › holds the storefront's seven links, Contact last
- Navigation › leaves the pages that aren't the shop to the footer
- Navigation › renders the site name as a link to /
- Navigation › highlights the active route
__tests__/Newsletter.test.tsx4 tests
- Newsletter › renders the signup form
- Newsletter › does not show the confirmation before submission
- Newsletter › shows a confirmation after the form is submitted
- Newsletter › posts the email to the Kit form endpoint
__tests__/og-image.test.ts11 tests
- site Open Graph image › answers with the image itself, not a redirect to it
- site Open Graph image › names the work it rendered, so staleness stays checkable end to end
- site Open Graph image › serves exactly the dimensions the meta tag advertises
- site Open Graph image › letterboxes rather than cropping, so the whole work survives
- site Open Graph image › gives up on a stalled source instead of running to the platform limit
- site Open Graph image › declares those same dimensions in the document head
- site Open Graph image › still answers at the declared size when the source cannot be read
- site Open Graph image › caches the degraded card briefly, so a transient failure is not sticky
- site Open Graph image › says so in the logs when it falls back
- site Open Graph image › redirects only when sharp itself cannot encode anything
- site Open Graph image › serves nothing rather than something broken when the catalogue is empty
__tests__/order.test.ts18 tests
- resolveOrder — prints › takes the title and price from the catalogue and the price table
- resolveOrder — prints › ignores a title and price smuggled in beside the ids
- resolveOrder — prints › marks a frame quote the way the page does
- resolveOrder — prints › refuses a size this work isn't offered in
- resolveOrder — prints › refuses lone-tree (sold out, hidden or unknown)
- resolveOrder — prints › refuses hidden-one (sold out, hidden or unknown)
- resolveOrder — prints › refuses no-such-work (sold out, hidden or unknown)
- resolveOrder — single cards › prices a greeting card from the fixed table
- resolveOrder — single cards › prices a postcard card from the fixed table
- resolveOrder — single cards › refuses an unknown card type and a sold-out work
- resolveOrder — gift packs › builds the month list from catalogue titles, in the order chosen
- resolveOrder — gift packs › refuses the wrong number of images, repeats, and unavailable ones
- resolveOrder — gift packs › refuses an unknown pack
- resolveOrder — malformed input › refuses null
- resolveOrder — malformed input › refuses "print"
- resolveOrder — malformed input › refuses {}
- resolveOrder — malformed input › refuses {"kind":"print"}
- resolveOrder — malformed input › refuses {"kind":"refund"}
__tests__/product-utils.test.ts9 tests
- deriveSizeType › classifies by aspect ratio
- deriveSizeType › uses correct boundaries
- deriveSizeType › falls back to the first available type when the guess isn't offered
- standardPrice › returns the lowest sale price for a size type
- standardPrice › returns a positive price for every known size type
- standardPrice › returns 0 for an unknown size type
- slugify › produces URL-safe, lowercase, hyphenated ids
- slugify › never returns an empty slug
- slugify › always yields a value matching the product ID pattern
__tests__/ProductCard.test.tsx9 tests
- ProductCard › renders the product title
- ProductCard › renders the starting price
- ProductCard › links to /products/{id} by default
- ProductCard › links to a custom basePath when provided
- ProductCard › renders the product image with the correct alt text
- ProductCard — sold out › shows a price for work that is still for sale
- ProductCard — sold out › replaces the price with Sold out
- ProductCard — sold out › drops the New and Sale badges rather than stacking them
- ProductCard — sold out › still links through to the work's page
__tests__/ProductDetailClient-home-photo.test.tsx4 tests
- a collector's photo, enlarged › keeps the collector's credit
- a collector's photo, enlarged › is still described as a photo of a collector's home
- a collector's photo, enlarged › shows no credit line when the collector asked not to be named
- a collector's photo, enlarged › does not carry the credit onto the painting itself
__tests__/reel-poll-budget.test.ts4 tests
- reel poll budget vs function timeouts › app/api/cron/instagram/route.ts leaves room to publish after the poll
- reel poll budget vs function timeouts › app/admin/instagram/page.tsx leaves room to publish after the poll
- reel poll budget vs function timeouts › keeps both publishing segments on the same timeout
- reel poll budget vs function timeouts › still allows several poll attempts
__tests__/season.test.ts4 tests
- currentSeason › maps spring months
- currentSeason › maps summer months
- currentSeason › maps fall months
- currentSeason › maps winter months
__tests__/security-headers.test.ts7 tests
- security headers › are applied to every route
- security headers › forbid other sites from framing the pages
- security headers › stop content sniffing and trim referrers
- security headers › switch off browser features the site never uses
- security headers › isolate the site's windows and responses from other origins
- security headers › don't advertise the framework
- security headers › leave HSTS to Vercel
__tests__/showcase.test.ts6 tests
- distinctShowcases › gives every gift-shop card a different work
- distinctShowcases › uses the calendar's chosen painting while it's for sale
- distinctShowcases › falls back to an automatic pick once that painting is hidden or sold out
- distinctShowcases › is stable: the same catalogue gives the same picks
- distinctShowcases › repeats only when there are fewer works than cards, and never crashes on none
- distinctShowcases › names the preference by product id, never by image path
__tests__/site-config.test.ts13 tests
- site identity lives in one file › no source file hard-codes the brand
- site identity lives in one file › no source file hard-codes the artist
- site identity lives in one file › no source file hard-codes the domain
- site identity lives in one file › no source file hard-codes the ownerEmail
- site identity lives in one file › no source file hard-codes the phone
- site identity lives in one file › no source file hard-codes the ga4
- site identity lives in one file › no source file hard-codes the instagram
- borrowed words live in the config too › no component hard-codes the collector's name
- the derived values still resolve correctly › builds the mail From header from the brand and domain
- the derived values still resolve correctly › strips the www for prose
- the derived values still resolve correctly › keeps the copyright claim pointed at the same values
- the derived values still resolve correctly › separates the public address from the inbox that receives notifications
- the derived values still resolve correctly › has a canonical url with no trailing slash
__tests__/site-content.test.tsx5 tests
- Testimonial › renders the quote and who said it
- Testimonial › renders nothing at all when there is no testimonial
- PressStrip › cites the publication and links to it
- PressStrip › names the article and the date
- PressStrip › renders nothing at all when there is no press mention
__tests__/version.test.ts2 tests
- /api/version › reports the deployed commit, uncached
- /api/version › reports null outside Vercel
End-to-end tests (127)
Playwright in Chromium, against a production build and a throwaway Postgres, on every pull request and every push to main. They include an axe accessibility scan of every public page type and a Content-Security-Policy check of every page. The image-upload test is opt-in and CI skips it, because it writes to the real image store. None of them runs against the live site: the suite refuses to start unless its database is local.
e2e/accessibility.spec.ts17 tests
- a11y: Paintings gallery (/)
- a11y: Painting detail (/products/council-beach)
- a11y: Photographs gallery (/photographs)
- a11y: Photograph detail (/photographs/ravenna-canola-photo)
- a11y: Locations map (/locations)
- a11y: Making Of (/making-of)
- a11y: Gift shop (/gift-shop)
- a11y: Gift shop pack builder (/gift-shop/card-pack)
- a11y: About (/about)
- a11y: FAQ (/faq)
- a11y: Contact (/contact)
- a11y: For Artists (/for-artists)
- a11y: Architecture (/architecture)
- a11y: Test inventory (/architecture/tests)
- a11y: Book a chat (/book)
- a11y: mobile menu open
- a11y: Buy Now order form open
e2e/admin.spec.ts3 tests
- admin login rejects a wrong password
- admin login accepts the correct password and reaches the product list
- unauthenticated access to the admin redirects to login
e2e/analytics.spec.ts3 tests
- a public page view is recorded, by path only
- the admin is never tracked
- the dashboard can stop counting this browser, and count it again
e2e/api-validation.spec.ts5 tests
- /api/buy rejects a request with missing required fields
- /api/buy silently accepts honeypot submissions without sending
- /api/buy refuses an order the catalogue can't back, before sending anything
- /api/contact rejects a request with missing required fields
- /api/contact rejects a single-word spam message
e2e/csp.spec.ts19 tests
- every page is served with the policy and a fresh nonce
- no CSP violations on /
- no CSP violations on /products/council-beach
- no CSP violations on /photographs
- no CSP violations on /photographs/ravenna-canola-photo
- no CSP violations on /locations
- no CSP violations on /making-of
- no CSP violations on /gift-shop
- no CSP violations on /gift-shop/calendar
- no CSP violations on /about
- no CSP violations on /faq
- no CSP violations on /contact
- no CSP violations on /for-artists
- no CSP violations on /architecture
- no CSP violations on /architecture/tests
- no CSP violations on /book
- no CSP violations on /admin/login
- no CSP violations when a film is played
- no CSP violations across the signed-in admin
e2e/forms.spec.ts14 tests
- contact form renders all fields and submit button
- contact form shows confirmation after successful submission
- contact form shows error message on API failure
- Buy Now button expands into an enquiry form
- Buy Now Cancel button restores the Buy Now button
- Buy Now form shows confirmation after successful submission
- Buy Now form shows error message on API failure
- booking form confirms after a successful submission
- booking form offers other ways to reach James when sending fails
- without JavaScript › /: every form posts
- without JavaScript › /contact: every form posts
- without JavaScript › /book: every form posts
- without JavaScript › /products/lora-bay: every form posts
- without JavaScript › a contact form sent before JavaScript runs keeps its fields out of the URL
e2e/giftshop.spec.ts9 tests
- gift shop landing shows three clickable pack cards, none coming soon
- calendar builder: selecting 12 images fills the months and completes
- calendar order submits the twelve chosen images, in order, as ids
- card pack builder uses numbered slots and supports deselect
- postcard pack builder requires 10 selections
- gift shop thumbnails come from visible artwork and all load
- the site's social preview image is served directly, from a work that is for sale
- card-pack order submits its 6 chosen images, in order, as ids
- postcard-pack order submits its 10 chosen images, in order, as ids
e2e/inspiration.spec.ts2 tests
- a painting with an inspiration photo shows the switcher thumbnail + map
- a painting without an inspiration photo shows no inspiration thumbnail
e2e/interactions.spec.ts8 tests
- size selector updates the displayed price
- Buy Now form shows the selected size and price
- category filter shows only matching paintings
- sort dropdown reorders the gallery
- related paintings section shows other paintings in the same category
- Buy Now form submits and shows confirmation
- unknown URL shows custom 404 page with link back to gallery
- a work's image opens full-screen and closes with the button or Escape
e2e/legacy-urls.spec.ts2 tests
- an old Squarespace URL for a live work redirects permanently to its page
- an old URL nothing matches is a real 404, not a redirect home
e2e/locations.spec.ts4 tests
- locations page plots the works that have coordinates
- works sharing a location share one pin, labelled with how many
- the location list focuses the map and links to the work
- Locations is reachable from the navigation
e2e/making-of.spec.ts9 tests
- the making-of page lists every published film
- no film and no full-size still is fetched before anyone presses play
- the stills go through the image optimiser
- clicking play mounts the player and asks for the film
- every film links through to its finished print
- a work's own page shows its film
- the photograph a painting came from is shown on the painting, not vice versa
- a work without a film shows no player
- Making Of is reachable from the navigation
e2e/newsletter.spec.ts2 tests
- newsletter signup shows confirmation on success
- newsletter signup shows an error message when the subscribe fails
e2e/photo-location.spec.ts3 tests
- a photograph with GPS shows the location map
- a panoramic photograph also shows the location map
- a photograph without GPS shows no location map
e2e/photographs.spec.ts6 tests
- photographs gallery shows all photographs
- photographs category filter shows only matching photographs
- photographs sort dropdown reorders the gallery
- clicking a photograph card navigates to the detail page
- back button on photograph detail returns to the gallery
- Buy Now on photograph detail submits and shows confirmation
e2e/search-home.spec.ts3 tests
- search box narrows the gallery and clear filters restores it
- New Arrivals and On Sale filter options narrow the gallery
- home page shows the collector testimonial
e2e/static-pages.spec.ts10 tests
- For Artists is reachable from the footer and shows the pitch
- Architecture is in the footer and covers the system section by section
- no word runs into the inline formatting before it
- every kind of response carries the security headers
- FAQ accordion opens and closes on click
- the test inventory is linked from Architecture and lists every test by name
- sitemap.xml lists static, gift-shop, and product pages
- product detail page carries Product JSON-LD with an offer
- In a Room switcher toggles the room mockup view
- mobile hamburger menu opens and navigates
e2e/upload.spec.ts1 test
- admin can upload a new painting and it appears in the list
e2e/zz-admin-instagram.spec.ts2 tests
- approving schedules a post on a Mon/Wed/Fri, and unapproving puts it back
- skipping takes a post out of the queue for good
e2e/zz-admin-manage.spec.ts4 tests
- toggling a painting invisible removes it from the public site and gift-shop pool
- editing a painting's price in the admin updates the public card
- admin column sort orders by title; ⠿ returns to draggable manual order
- drag-to-reorder moves a row and persists; restored after
e2e/zz-rate-limit.spec.ts1 test
- admin login locks out after 5 failed attempts
Beyond the test suites
- Dependency audit —
npm auditon production dependencies, the first step of every CI run. - Static analysis — Gitleaks over every commit for credentials, and Semgrep over the source; both on every pull request.
- Security scan — the OWASP ZAP baseline scan of the CI build, after the end-to-end tests pass.
- Smoke test — after every production deploy, confirms the new commit is the one live and that key pages render with their security headers.
- Health check — every 10 minutes, the database and four live pages; emails when that changes.
- Load test — a k6 script, run by hand; the latest results are in Architecture §8.