Fall Sale — save up to 35% on selected prints. Prices as marked.
Georgian Bay Artists

Architecture

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 audit on 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.

The Studio List

New paintings, first.

Be the first to see new Georgian Bay paintings and prints, hear about collection releases, and get subscriber-only sale previews. A few emails a season — no clutter.

Unsubscribe anytime. We never share your email.