diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 34af9eeda..31e7a2484 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -5,14 +5,10 @@ updates: versioning-strategy: "increase" schedule: interval: "weekly" - open-pull-requests-limit: 20 + open-pull-requests-limit: 4 labels: - "type: dependencies 🔗" - "automerge 🤞" - # Ignore list: pin packages that should not be updated automatically - ignore: - - dependency-name: "zod" - update-types: ["version-update:semver-patch", "version-update:semver-minor", "version-update:semver-major"] - package-ecosystem: "github-actions" directory: "/" schedule: diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml index 91fe76892..476ba88ea 100644 --- a/.github/workflows/build-and-test.yml +++ b/.github/workflows/build-and-test.yml @@ -20,7 +20,6 @@ jobs: # Define environment variables once at the job level # These will be available to ALL steps in this job - # @TODO: Same issue with type-check.yml. These are real keys set on GitHub. But the production build only occurs on Vercel, and it's the only place that needs real keys. Once we set up a test framework using Docker containers for Suprabase, Upstash, etc., we should replace these keys with test keys. env: CONVERTKIT_API_KEY: ${{ secrets.CONVERTKIT_API_KEY }} CONVERTKIT_FORM_ID: ${{ secrets.CONVERTKIT_FORM_ID }} @@ -94,6 +93,9 @@ jobs: - name: Start Supabase stack run: npm run containers:supabase:start + - name: Apply Supabase migrations + run: npm run containers:supabase:db-push + - name: Run Playwright E2E tests run: npx playwright test env: diff --git a/.vscode/settings.json b/.vscode/settings.json index 389b95cad..30f7b3dfa 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -44,6 +44,7 @@ "Joomla", "jscoverage", "Karamov", + "keyup", "KHTML", "koko", "labelledby", @@ -69,9 +70,11 @@ "oklch", "Onest", "optin", + "PGRST", "pids", "pleroma", "Poslovski", + "Postg", "Qualys", "registrator", "repost", diff --git a/@types/window.d.ts b/@types/window.d.ts index dd4b7fac4..d224c716a 100644 --- a/@types/window.d.ts +++ b/@types/window.d.ts @@ -52,6 +52,11 @@ declare global { */ isPlaywrightControlled?: boolean + /** + * Allows Playwright tests to disable PWA service worker registration + */ + __disableServiceWorkerForE2E?: boolean + /** * Snapshot of environment-client results for Playwright assertions */ diff --git a/README.md b/README.md index 15ce98db8..74e69a76e 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,40 @@ git push -u origin feature/your-feature-name - Branch name must follow conventions - Cannot commit directly to main +### Local Development Server + +Use the helper script below to start Astro with `.env.development` automatically loaded: + +```bash +npm run dev:env +``` + +The underlying `npm run dev` command remains unchanged for CI and Vercel; `dev:env` is just a convenience for local shells so API routes that depend on `process.env` (cron handlers, email providers, etc.) behave the same way they do in production. + +## Supabase Production Initialization + +Provisioning the hosted Supabase project uses the same SQL migrations that back local development. When you need to initialize (or update) the production database: + +1. Authenticate the Supabase CLI (only required once per machine): + + ```bash + npx supabase login + ``` + +1. Link the CLI to the production project (replace the placeholder reference): + + ```bash + npx supabase link --project-ref your-production-project-ref --workdir suprabase + ``` + +1. Apply the latest migrations and RLS policies: + + ```bash + npm run supabase:db:push + ``` + +The `supabase:db:push` script runs `supabase db push` against the linked project, ensuring every table, index, and policy in `suprabase/migrations` is kept in sync with production. + ## Coding Standards ### Component Architecture diff --git a/_TODO.md b/_TODO.md index 49117b8c0..677af9f25 100644 --- a/_TODO.md +++ b/_TODO.md @@ -4,6 +4,12 @@ Implement mitigations in test/e2e/specs/07-performance/PERFORMANCE.md +## GitHub + +- Make sure actions workflows are working correctly after performance tests pass and whole suite is green +- Change Dependabut to open a single PR with all dependency updates +- Add 'hotfix' branch and add branch protection rules + ## Analytics Vercel Analytics @@ -11,27 +17,43 @@ Vercel Analytics ## Themepicker tooltips, extra themes - Add additional themes -- @TODO: add tooltip that makes use of the description field +- Add tooltip that makes use of the description field ## Sentry feedback, chat bot tying into my phone and email See note in src/components/scripts/sentry/client.ts - "User Feedback - allow users to report issues" -## src/pages/api next steps +## Uppy file uploads from contact form + +docs/CONTACT_FORM.md + +## Search + +Add Upstash Search as a Vercel Marketplace Integration. + +Lunr is a JS search library using an inverted index. Client-side search for statically hosted pages. + +### [`@jackcarey/astro-lunr`](https://www.npmjs.com/package/@jackcarey/astro-lunr) + +### [`@siverv/astro-lunr`](https://www.npmjs.com/package/@siverv/astro-lunr) + +## Email Templates -E2E Starting Point +Right now we're using string literals to define HTML email templates for site mails. We should use Nunjucks with the rule-checking for valid CSS in HTML emails like we have in the corporate email footer repo. -Stabilize infra first: run npm run containers:up, npm run containers:wait, npm run containers:supabase:start, and the dev server. Keep containers:logs and containers:supabase:logs tailing in another terminal so every mock failure is visible before Playwright runs. +## @TODO: Provide button to turn off animation in Hero -Create a shared Playwright "mocks ready" fixture: add a helper that checks process.env.E2E_MOCKS === '1' and pings `http://127.0.0.1:8079/` plus the two WireMock endpoints before each suite. That gives quick feedback if someone forgets the setup commands. -Implementation order +"Scaling/zooming animations are problematic for accessibility, as they are a common trigger for certain types of migraine. If you need to include such animations on your website, you should provide a control to allow users to turn off animations, preferably site-wide. Also, consider making use of the prefers-reduced-motion media feature — use it to write a media query that will turn off animations if the user has reduced animation specified in their system preferences. " -08-api: start with these since their success hinges entirely on the mocks. For each test, assert the HTTP response and inspect the mock's request logs (WireMock /__admin/requests) to prove the backend call happened. Adding the cron tests here makes sense—just exercise the GET endpoints via page.request or Playwright's API testing capability so you don't need UI plumbing. +## Color vars + +brand primary: #001733 +brand secondary: #0062B6 -Cron coverage: write three tests that hit cleanup-confirmations, newsletter-reminders, etc., using the mock stack. Seed Supabase/Redis with known values before each test (scripts in containers) and assert the mocks see the expected outbound traffic. +ring (1px), ring-2, ring-4 +accent -03-forms: once the API layer is stable, wire the UI flows. Use Playwright to submit each form, but assert success by checking the mock mappings were triggered, not just the UI toast. -Consent Preferences (@wip): convert it to use the same helper that verifies mocked Upstash REST and Supabase responses. This test should (1) toggle UI controls, (2) check the outbound request via the mock logs, and (3) read back seeded data to confirm persistence. +text-white, other default Tailwind colors ## Typing client-side API calls and SSR API endpoints @@ -43,12 +65,12 @@ Shared Types vs Swagger / Keeping Docs in Sync - Cons: no generated docs/SDKs; discipline is required to keep manual docs current. - How to enforce: treat the contract files as the single source of truth, add lint rules banning request/response literal types outside _contracts, and add lightweight contract tests that instantiate each type against the endpoint handler (failing if fields diverge). -2. Code-first OpenAPI (Zod or TS schemas → OpenAPI) +1. Code-first OpenAPI (Zod or TS schemas → OpenAPI) - Define schemas in Zod/Valibot (or ts-rest) alongside the endpoint. Generate OpenAPI JSON plus TypeScript types from those schemas. Docs (Swagger UI/Redoc) and any client SDKs come from the generated spec, so they're always in sync. - Guarantees: CI regenerates the spec and fails when the checked-in artifact is stale; endpoint handlers reuse the same schema for runtime validation, so a mismatch cannot compile. -3. Spec-first OpenAPI + Swagger Codegen +1. Spec-first OpenAPI + Swagger Codegen - Maintain an OpenAPI YAML/JSON file as the source of truth, run Swagger Codegen (or openapi-typescript) to produce both server stubs and client SDKs. - Guarantees: developers edit the spec, run codegen (enforced via a pre-commit/CI task), and the generated server stubs remind you to implement every path/verb. Documentation pages (Swagger UI) are rendered straight from the same spec, so they inherently match the implementation. @@ -67,24 +89,6 @@ Lighthouse audits (6) - Integration pending Newsletter double opt-in (6) - Email testing infrastructure Axe accessibility (2) - axe-core integration -## Search - -Add Upstash Search as a Vercel Marketplace Integration. - -## Email Templates - -Right now we're using string literals to define HTML email templates for site mails. We should use Nunjucks with the rule-checking for valid CSS in HTML emails like we have in the corporate email footer repo. - -## Color vars - -brand primary: #001733 -brand secondary: #0062B6 - -ring (1px), ring-2, ring-4 -accent - -text-white, other default Tailwind colors - ## Axe tags cat.aria: Rules related to Accessible Rich Internet Applications (ARIA) attributes and roles. @@ -100,6 +104,12 @@ cat.structure: Rules related to the document's overall structure, like the prope cat.tables: Rules for data tables, including headers and associations. cat.text-alternatives: Rules for ensuring that text alternatives are provided for non-text content, such as images. +## @TODO: Add Check HTML Links to test workflow + +npm i -D check-html-links +npx check-html-links _site +`https://github.com/modernweb-dev/rocket/tree/main/packages/check-html-links` + ## Social Media Preview Cards Looking at the social-card endpoint implementation, it's designed to work with third-party screenshot services, not the social networks themselves. @@ -117,7 +127,7 @@ Social networks like Twitter, Facebook, LinkedIn, etc. don't screenshot HTML pag - Direct image URLs (PNG, JPEG, etc.) - Standard dimensions (1200x630px for most platforms) -The Intended Workflow +**The Intended Workflow** This endpoint is designed to integrate with screenshot services like: @@ -127,7 +137,7 @@ This endpoint is designed to integrate with screenshot services like: - ScreenshotOne or ApiFlash - Dedicated screenshot APIs - Satori - Convert HTML/CSS to SVG/PNG -Current Limitation +**Current Limitation** As implemented, this endpoint would need an additional step to be useful for social sharing: @@ -141,71 +151,67 @@ For a production Astro site, you'd typically: - Pre-generate images at build time for static content - Use a screenshot service that can be called from your endpoint to return actual images -## @TODO: Use Confetti on CTA forms +**Social Media Preview Generators** -`canvas-confetti` -https://github.com/catdad/canvas-confetti -https://www.kirilv.com/canvas-confetti/ +There are several integrations available that vary based on the library they use to create an image file to snapshot, whether they allow the template for generating the image to be modified, and what options they provide for output. -## @TODO: Use the Page Visibility API to pause videos, image carousels, and animations +[`astro-og-canvas`](https://www.npmjs.com/package/astro-og-canvas) -Stop unnecessary processes when the user doesn't see the page or inversely to perform background actions. +- Most popular option (~660 weekly d/l). Generates images at **run time**. +- Uses **`canvaskit-wasm`** for rendering +- Uses plain color or gradient background. Provide title, description, and logo (displayed at top left of card). +- Can't set size of final card. -## @TODO: "Add to Calendar" button +[`astro-satori`](https://www.npmjs.com/package/astro-satori) -Google Calendar, Apple Calendar, Yahoo Calender, Microsoft 365, Outlook, and Teams, and generate iCal/ics files (for all other calendars and cases). +- Moderately popular option (~230 weekly d/l). Generates images at **run time**. +- Uses Vercel's **Satori** library for rendering (entirely done in JS with limitations on what CSS can be used). Satori is a library for generating SVG strings from pure HTML and CSS. +- Size of final card can be set. +- Seems opinionated, but it might be possible to have a lot of control (not sure). +- Output format? -https://github.com/add2cal/add-to-calendar-button -https://add-to-calendar-button.com/ +[`astro-opengraph-image`](https://www.npmjs.com/package/@altano/astro-opengraph-image#fn-filename-change) -## @TODO: Add Check HTML Links to test workflow +- Uses **Satori**. Has dependencies on [`@resvg/resvg-wasm`](https://www.npmjs.com/package/@resvg/resvg-wasm) and Sharp. Middleware integration. Generates images at **run time**. +- Provides element to add OG tags in document ``. +- Very flexible, you can provide the Astro template to generate the card. -npm i -D check-html-links -npx check-html-links _site -https://github.com/modernweb-dev/rocket/tree/main/packages/check-html-links +[Astro Open Graph Image](https://www.npmjs.com/package/astro-og-image) -## @TODO: Provide button to turn off animation in Hero +- Uses **Puppeteer**. Generates images at **build time**. +- You can provide the Astro template to generate the card. +- Requires providing a `baseHead` property in page templates. -"Scaling/zooming animations are problematic for accessibility, as they are a common trigger for certain types of migraine. If you need to include such animations on your website, you should provide a control to allow users to turn off animations, preferably site-wide. Also, consider making use of the prefers-reduced-motion media feature — use it to write a media query that will turn off animations if the user has reduced animation specified in their system preferences. " +[Astro Open Graph Image Generator](https://www.npmjs.com/package/@cyberkoalastudios/og-image-generator) -## Fix offline page +- Uses **Puppeteer**. Has dependencies on [`canvaskit-wasm`](https://www.npmjs.com/package/@resvg/resvg-wasm) and Sharp. +- You can set the background image. No option to set the size of the card. +- Manually add OF properties on `` element. Flexible. -- It should look like any other page, but have interactivity like navigation and other links disabled. -- The test cases should check for if they're on the offline page, and return passed for an inverse. Like the smoke test that makes sure navigation is available, it would return true if navigation is present but disabled in the same test case. +## @TODO: Use Confetti on CTA forms -## @TODO: Handle `@media (prefers-reduced-motion: reduce)` +`canvas-confetti` +`https://github.com/catdad/canvas-confetti` +`https://www.kirilv.com/canvas-confetti/` -Stop the Hero Greensocks animation when `@media (prefers-reduced-motion: reduce)`, using `window.mediaQuery()`. Handle user preference for reduced motion on animations, doing this also with a listener like for browser theme preference +## @TODO: Use the Page Visibility API to pause videos, image carousels, and animations -```css -@media (prefers-reduced-motion) { - /* styles to apply if the user's settings are set to reduced motion */ -} -``` +Stop unnecessary processes when the user doesn't see the page or inversely to perform background actions. -```typescript -const mediaQueryList = window.matchMedia('(prefers-reduced-motion)') // not sure what the inverse is to match for so that there's a listener for both the prefers-reduced-motion state and the doesn't-care state -mediaQueryList.addEventListener(event => { - if (event.type === 'change') {} -}) -``` +## @TODO: "Add to Calendar" button -## @TODO: Set up webmentions +Google Calendar, Apple Calendar, Yahoo Calender, Microsoft 365, Outlook, and Teams, and generate iCal/ics files (for all other calendars and cases). -This code goes in `_layouts/layouts/base.njk` after the last ` - -{%- endif -%} -``` +## @TODO: Set up webmentions -There's a filter roughed out for the webmentions. +Needs to add real API key and test ## @TODO: SCSS Use clothoid corners with border-radius -https://onotakehiko.dev/clothoid/ +`https://onotakehiko.dev/clothoid/` `@TODO: SCSS Make sure accent-color or styling for checkboxes/radio button groups is set up. Sets the colour used by checkboxes and radio buttons, as well as range fields and progress indicators. The accent colour is inherited` @@ -215,95 +221,10 @@ https://onotakehiko.dev/clothoid/ } ``` -## @TODO: SCSS Replace all `:focus pseudoselectors` with `:focus-visible` - -```css -/* Focusing the button with a keyboard will show a dashed black line. */ -button:focus-visible { - outline: 4px dashed black; -} - -/* Focusing the button with a mouse, touch, or stylus will show a subtle drop shadow. */ -button:focus:not(:focus-visible) { - outline: none; - box-shadow: 1px 1px 5px rgba(1, 1, 0, .7); -} -``` - ## @TODO: Refactor modals Modals should be wrapped in the `` element and use programmatic methods to display - `showModal()` to disable the area outside of the modal (handles `esc` keypress natively) and `show()` to allow interaction outside the modal, along with `close()`. -## @TODO: Fix Favicon workflow - -Right now, the `eleventy-favicon` plugin is used to generate `favicon.ico`, `favicon.svg`, and `apple-touch-icon.png` in the root directory. It provides a shortcode to use for outputting -`` markup in the document head: - -```nunjucks -{% favicon buildPaths.faviconSvgSourceFilename %} -``` - -The shortcode generates this markup, notice the iOS-specific `rel` type in the third link: - -```html - - - -``` - -SVG favicons are only supported across 74% of browsers. We have to provide a fallback version for Internet Explorer and Safari. - -The plugin functionality for generating favicons should be moved to a Gulp task, and the HTML markup hard coded in `_layouts/components/head/meta.njk` so that the `` tags can use conditional -media queries based on whether the user has a preference for dark mode set and their browser title -bar is therefore in a dark theme: - -```html - - -``` - -```typescript -// select the favicon 👉 -const faviconEl = document.querySelector('link[rel="icon"]') - -// watch for changes 🕵️ -const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)') -mediaQuery.addEventListener('change', themeChange) - -// listener 👂 -function themeChange(event) { - if (event.matches) { - faviconEl.setAttribute('href', 'favicon-dark.png') - } else { - faviconEl.setAttribute('href', 'favicon-light.png') - } -} -``` - -## @TODO: Theme preference handling - -Are we listening for an event that the user changes their browser's theme preference, and updating our theme if they do? And is the initial theme of our site set based on the browser's theme preference? Use a listener for browser theme preference. - -```css -@media (prefers-color-scheme: dark) {} -@media (prefers-color-scheme: light) {} -``` - -```typescript -const mediaQueryList = window.matchMedia('(prefers-color-scheme: dark)') -mediaQueryList.addEventListener(event => { - if (event.type === 'change') {} -}) -``` - ## @TODO: Add for iOS Specifying a Launch Screen Image @@ -314,43 +235,27 @@ On iOS, similar to native applications, you can specify a launch screen image th ``` -# Astro 3rd-Party Integrations +# Astro 3rd-Party Integrations, Eleventy Migration -## Eleventy Migration - -Eleventy plugins that don't yet have identified equivalents for Astro. +## Eleventy plugins that don't yet have identified equivalents for Astro. - **`eleventy-plugin-inclusive-language`** -Outputs command line warnings for weasel words like "obviously", "basically", etc. - -```'simply,obviously,basically,of course,clearly,just,everyone knows,however,easy'``` - -- **`eleventy-plugin-rss`** - -RSS feed generator, adds shortcode filters absoluteUrl, dateToRfc3339, dateToRfc822. - -- **`eleventy-plugin-social-images`** - -Generates images as headers for use in social shares - -- **`eleventy-plugin-schema`** - -Provides a shortcode to generate a JSON-LD script per-page including the `', - email: 'test@example.com', - message: '', - consent: true, - }, + const loggedRequest = await wiremock.resend.expectRequest({ + method: 'POST', + urlPath: RESEND_EMAIL_PATH, + bodyIncludes: [uniqueEmail, 'contact@webstackbuilders.com'], }) - // Should either accept (after sanitization) or reject - expect([200, 201, 400, 422]).toContain(response.status()) - }) - - test('@ready contact rate limits submissions', async ({ request }) => { - // Expected: Should have rate limiting - const requests = [] - - for (let i = 0; i < 10; i++) { - requests.push( - request.post('/api/contact', { - data: { - name: 'Test User', - email: `test${i}@example.com`, - message: `Test message ${i}`, - consent: true, - }, - }) - ) + if (!loggedRequest) { + throw new Error('Resend mock did not capture the transactional email payload') } - const responses = await Promise.all(requests) - const rateLimited = responses.some((r) => r.status() === 429) + const payload = JSON.parse(loggedRequest.request.body ?? '{}') as { + from: string + to: string | string[] + subject: string + } - // Rate limiting may or may not be implemented - expect(typeof rateLimited).toBe('boolean') + expect(payload.from).toContain('contact@webstackbuilders.com') + if (Array.isArray(payload.to)) { + expect(payload.to).toContain('info@webstackbuilders.com') + } else { + expect(payload.to).toBe('info@webstackbuilders.com') + } + expect(payload.subject).toContain('Integration Bot') }) - test('@ready contact accepts optional phone field', async ({ request }) => { - // Expected: Phone field should be optional - const response = await request.post('/api/contact', { - data: { - name: 'Test User', - email: 'test@example.com', - phone: '+1234567890', - message: 'Test message', - consent: true, + test('@mocks rejects invalid submissions before reaching Resend', async ({ request }) => { + const invalidEmail = `invalid-contact-${Date.now()}@example.com` + const response = await request.post(CONTACT_ENDPOINT, { + headers: { + 'x-e2e-mocks': '1', }, - }) - - expect([200, 201, 400, 422]).toContain(response.status()) - }) - - test('@ready contact accepts optional company field', async ({ request }) => { - // Expected: Company field should be optional - const response = await request.post('/api/contact', { data: { - name: 'Test User', - email: 'test@example.com', - company: 'Test Company Inc', - message: 'Test message', - consent: true, + name: 'x', + email: invalidEmail, + message: 'short', }, }) - expect([200, 201, 400, 422]).toContain(response.status()) + expect(response.status()).toBe(400) + const requests = await wiremock.resend.findRequests({ + method: 'POST', + urlPath: RESEND_EMAIL_PATH, + bodyIncludes: invalidEmail, + }) + expect(requests.length).toBe(0) }) }) diff --git a/test/e2e/specs/08-api/newsletter-api.spec.ts b/test/e2e/specs/08-api/newsletter-api.spec.ts index 96ed1eca9..8d52edeca 100644 --- a/test/e2e/specs/08-api/newsletter-api.spec.ts +++ b/test/e2e/specs/08-api/newsletter-api.spec.ts @@ -1,151 +1,76 @@ -/** - * Newsletter form API route wrapper of Vercel function - * endpoint for E2E testing of newsletter subscription - * - * @see api/newsletter/ - */ +import { expect, test, wiremock, mocksEnabled } from '@test/e2e/helpers' -import { test, expect } from '@test/e2e/helpers' +const NEWSLETTER_ENDPOINT = '/api/newsletter' +const RESEND_EMAIL_PATH = '/emails' -test.describe('Newsletter API', () => { - test('@ready newsletter endpoint accepts POST', async ({ request }) => { - // Expected: POST /api/newsletter should accept requests - const response = await request.post('/api/newsletter', { - data: { - email: 'test@example.com', - consentGiven: true, - }, - }) +test.describe('Newsletter API integrations', () => { + if (!mocksEnabled) { + test.skip(true, 'E2E_MOCKS=1 is required to run newsletter API integration tests') + } - expect([200, 201, 400, 422]).toContain(response.status()) - }) - - test('@ready newsletter validates email format', async ({ request }) => { - // Expected: Invalid email should return 400/422 - const response = await request.post('/api/newsletter', { - data: { - email: 'invalid-email', - consentGiven: true, - }, - }) - - expect([400, 422]).toContain(response.status()) - - const body = await response.json() - expect(body.error || body.message).toBeTruthy() - }) + test.describe.configure({ mode: 'serial' }) - test('@ready newsletter requires consent', async ({ request }) => { - // Expected: Missing consent should fail - const response = await request.post('/api/newsletter', { - data: { - email: 'test@example.com', - consentGiven: false, + test('@mocks sends double opt-in email through Resend mock', async ({ request }) => { + const uniqueEmail = `newsletter-${Date.now()}@example.com` + const response = await request.post(NEWSLETTER_ENDPOINT, { + headers: { + 'x-e2e-mocks': '1', }, - }) - - expect([400, 422]).toContain(response.status()) - }) - - test('@ready newsletter returns success for valid request', async ({ request }) => { - // Expected: Valid request should return 200/201 - const response = await request.post('/api/newsletter', { data: { - email: `test+${Date.now()}@example.com`, + email: uniqueEmail, consentGiven: true, }, }) - expect([200, 201]).toContain(response.status()) - + expect(response.status()).toBe(200) const body = await response.json() - expect(body.success || body.message).toBeTruthy() - }) - - test('@ready newsletter handles duplicate subscriptions', async ({ request }) => { - // Expected: Should handle duplicate email gracefully - const email = `duplicate+${Date.now()}@example.com` + expect(body.requiresConfirmation).toBe(true) - // First subscription - await request.post('/api/newsletter', { - data: { email, consentGiven: true }, + const loggedRequest = await wiremock.resend.expectRequest({ + method: 'POST', + urlPath: RESEND_EMAIL_PATH, + bodyIncludes: [uniqueEmail, 'newsletter@webstackbuilders.com'], }) - // Second subscription with same email - const response = await request.post('/api/newsletter', { - data: { email, consentGiven: true }, - }) - - // Should either succeed or return friendly error - expect([200, 201, 409]).toContain(response.status()) - }) + if (!loggedRequest) { + throw new Error('Resend mock did not capture the newsletter double opt-in payload') + } - test.skip('@wip newsletter returns proper content type', async ({ request }) => { - // Expected: Should return JSON - const response = await request.post('/api/newsletter', { - data: { - email: 'test@example.com', - consentGiven: true, - }, - }) + const payload = JSON.parse(loggedRequest.request.body ?? '{}') as { + from: string + to: string | string[] + html?: string + text?: string + } - const contentType = response.headers()['content-type'] - expect(contentType).toContain('application/json') + expect(payload.from).toContain('newsletter@webstackbuilders.com') + const confirmLink = payload.html?.match(/newsletter\/confirm\/([A-Za-z0-9_-]+)/) + expect(confirmLink?.[1]).toBeTruthy() + if (Array.isArray(payload.to)) { + expect(payload.to).toContain(uniqueEmail) + } else { + expect(payload.to).toBe(uniqueEmail) + } }) - test('@ready newsletter validates email length', async ({ request }) => { - // Expected: Excessively long email should fail - const longEmail = 'a'.repeat(300) + '@example.com' - - const response = await request.post('/api/newsletter', { - data: { - email: longEmail, - consentGiven: true, + test('@mocks requires consent before sending any emails', async ({ request }) => { + const emailWithoutConsent = 'noconsent@example.com' + const response = await request.post(NEWSLETTER_ENDPOINT, { + headers: { + 'x-e2e-mocks': '1', }, - }) - - expect([400, 422]).toContain(response.status()) - }) - - test('@ready newsletter rejects missing email', async ({ request }) => { - // Expected: Missing email field should return 400 - const response = await request.post('/api/newsletter', { data: { - consentGiven: true, + email: emailWithoutConsent, + consentGiven: false, }, }) - expect([400, 422]).toContain(response.status()) - }) - - test('@ready newsletter handles malformed JSON', async ({ request }) => { - // Expected: Invalid JSON should return 400 - const response = await request.post('/api/newsletter', { - data: 'this is not json', + expect(response.status()).toBe(400) + const requests = await wiremock.resend.findRequests({ + method: 'POST', + urlPath: RESEND_EMAIL_PATH, + bodyIncludes: emailWithoutConsent, }) - - expect([400, 422, 500]).toContain(response.status()) - }) - - test('@ready newsletter rate limits requests', async ({ request }) => { - // Expected: Should have rate limiting to prevent abuse - const requests = [] - - for (let i = 0; i < 20; i++) { - requests.push( - request.post('/api/newsletter', { - data: { - email: `test${i}@example.com`, - consentGiven: true, - }, - }) - ) - } - - const responses = await Promise.all(requests) - const rateLimited = responses.some((r) => r.status() === 429) - - // May or may not have rate limiting implemented - expect(typeof rateLimited).toBe('boolean') + expect(requests.length).toBe(0) }) }) diff --git a/test/e2e/specs/08-api/supabase.spec.ts b/test/e2e/specs/08-api/supabase.spec.ts index 352cad542..3a26ac5b6 100644 --- a/test/e2e/specs/08-api/supabase.spec.ts +++ b/test/e2e/specs/08-api/supabase.spec.ts @@ -1,56 +1,279 @@ /** * Supabase Database Tests - * Tests for Supabase client configuration and RLS policies - * @see src/components/scripts/consent/db/supabase.ts - * These tests are skipped pending proper e2e test setup + * Verifies Supabase client environment configuration and row-level security expectations. */ -import { test } from '@test/e2e/helpers' +import { createClient, type PostgrestError, type SupabaseClient } from '@supabase/supabase-js' +import { randomUUID } from 'node:crypto' +import { env } from 'node:process' +import { expect, test } from '@test/e2e/helpers' + +const SUPABASE_URL = env['SUPABASE_URL']?.replace(/\/$/, '') +const SUPABASE_SERVICE_ROLE_KEY = env['SUPABASE_SERVICE_ROLE_KEY'] +const SUPABASE_ANON_KEY = env['SUPABASE_KEY'] +const SUPABASE_RLS_TABLE = 'consent_records' +const SUPABASE_DISABLED = env['E2E_SUPABASE_FALLBACK'] === '1' + +const supabaseReady = Boolean( + !SUPABASE_DISABLED && + SUPABASE_URL && + SUPABASE_SERVICE_ROLE_KEY && + SUPABASE_ANON_KEY +) + +const createSupabaseClient = (key: string): SupabaseClient => + createClient(SUPABASE_URL!, key, { + auth: { + autoRefreshToken: false, + persistSession: false, + }, + }) + +const supabaseAdminClient = supabaseReady ? createSupabaseClient(SUPABASE_SERVICE_ROLE_KEY!) : null +const supabaseAnonClient = supabaseReady ? createSupabaseClient(SUPABASE_ANON_KEY!) : null + +const TEST_SOURCE = 'supabase_e2e' +const TEST_PRIVACY_VERSION = '1970-01-01' +const TEST_USER_AGENT = 'playwright/supabase' + +const createdRecordIds: string[] = [] + +const skipUnlessChromium = (browserName: string) => { + test.skip(browserName !== 'chromium', 'Supabase API tests only run once per suite') +} + +const buildConsentRecordPayload = () => ({ + 'data_subject_id': randomUUID(), + 'email': `supabase-e2e-${randomUUID()}@example.com`, + 'purposes': ['marketing'], + 'source': TEST_SOURCE, + 'user_agent': TEST_USER_AGENT, + 'privacy_policy_version': TEST_PRIVACY_VERSION, + 'consent_text': 'Captured for e2e Supabase coverage', +}) + +const recordCleanup = async () => { + if (!supabaseAdminClient || createdRecordIds.length === 0) { + return + } + + await supabaseAdminClient + .from(SUPABASE_RLS_TABLE) + .delete() + .in('id', [...createdRecordIds]) + + createdRecordIds.length = 0 +} + +test.afterEach(async () => { + if (supabaseReady) { + await recordCleanup() + } +}) + +const skipSupabaseReason = !supabaseReady + ? 'Supabase environment is not configured or fallback mode is enabled.' + : undefined test.describe('Supabase Database API', () => { - test.skip('Supabase Configuration - should use correct environment variables', async () => { - // TODO: Test that Supabase clients are configured with correct URLs and keys + test.describe.configure({ mode: 'serial' }) + if (skipSupabaseReason) { + test.skip(true, skipSupabaseReason) + } + + test('Supabase Configuration - should use correct environment variables', async ({ browserName }) => { + skipUnlessChromium(browserName) + expect(SUPABASE_URL).toBeTruthy() + expect(SUPABASE_SERVICE_ROLE_KEY).toMatch(/^sb_secret_/) + expect(SUPABASE_ANON_KEY).toMatch(/^sb_/) + + const { error, count } = await supabaseAdminClient! + .from(SUPABASE_RLS_TABLE) + .select('*', { count: 'exact', head: true }) + + expect(error).toBeNull() + expect(typeof count === 'number').toBe(true) }) - test.skip('Supabase Configuration - should create clients with proper auth settings', async () => { - // TODO: Test that admin client bypasses RLS and public client respects RLS + test('Supabase Configuration - should create clients with proper auth settings', async ({ browserName }) => { + skipUnlessChromium(browserName) + const record = buildConsentRecordPayload() + const { data, error } = await supabaseAdminClient! + .from(SUPABASE_RLS_TABLE) + .insert(record) + .select() + .single() + + expect(error).toBeNull() + expect(data?.source).toBe(TEST_SOURCE) + createdRecordIds.push(data!.id) + + const { error: anonError } = await supabaseAnonClient! + .from(SUPABASE_RLS_TABLE) + .insert(buildConsentRecordPayload()) + + expectRlsFailure({ error: anonError }) }) }) +const expectRlsFailure = (result: { error: PostgrestError | null; data?: unknown }) => { + const { error, data } = result + if (error) { + expect(['42501', 'PGRST301']).toContain(error.code) + return + } + + if (Array.isArray(data)) { + expect(data.length).toBe(0) + } else { + expect(data ?? null).toBeNull() + } +} + test.describe('RLS Policies', () => { + test.describe.configure({ mode: 'serial' }) + if (skipSupabaseReason) { + test.skip(true, skipSupabaseReason) + } + test.describe('Service Role (Admin)', () => { - test.skip('can insert records', async () => { - // TODO: Test that admin service role can insert consent records + test('can insert records', async ({ browserName }) => { + skipUnlessChromium(browserName) + const payload = buildConsentRecordPayload() + const { data, error } = await supabaseAdminClient! + .from(SUPABASE_RLS_TABLE) + .insert(payload) + .select() + .single() + + expect(error).toBeNull() + expect(data?.email).toBe(payload.email) + createdRecordIds.push(data!.id) }) - test.skip('can read records', async () => { - // TODO: Test that admin service role can read consent records + test('can read records', async ({ browserName }) => { + skipUnlessChromium(browserName) + const insertPayload = buildConsentRecordPayload() + const { data: inserted } = await supabaseAdminClient! + .from(SUPABASE_RLS_TABLE) + .insert(insertPayload) + .select() + .single() + createdRecordIds.push(inserted!.id) + + const { data, error } = await supabaseAdminClient! + .from(SUPABASE_RLS_TABLE) + .select('*') + .eq('id', inserted!.id) + .single() + + expect(error).toBeNull() + expect(data?.email).toBe(insertPayload.email) }) - test.skip('can update records', async () => { - // TODO: Test that admin service role can update consent records + test('can update records', async ({ browserName }) => { + skipUnlessChromium(browserName) + const insertPayload = buildConsentRecordPayload() + const { data: inserted } = await supabaseAdminClient! + .from(SUPABASE_RLS_TABLE) + .insert(insertPayload) + .select() + .single() + createdRecordIds.push(inserted!.id) + + const updatedText = 'Updated via admin RLS test' + const { data, error } = await supabaseAdminClient! + .from(SUPABASE_RLS_TABLE) + .update(Object.fromEntries([[ 'consent_text', updatedText ]])) + .eq('id', inserted!.id) + .select() + .single() + + expect(error).toBeNull() + expect(data?.consent_text).toBe(updatedText) }) - test.skip('can delete records', async () => { - // TODO: Test that admin service role can delete consent records + test('can delete records', async ({ browserName }) => { + skipUnlessChromium(browserName) + const insertPayload = buildConsentRecordPayload() + const { data: inserted } = await supabaseAdminClient! + .from(SUPABASE_RLS_TABLE) + .insert(insertPayload) + .select() + .single() + + const { error } = await supabaseAdminClient! + .from(SUPABASE_RLS_TABLE) + .delete() + .eq('id', inserted!.id) + + expect(error).toBeNull() }) }) test.describe('Public Client (Anon)', () => { - test.skip('cannot read records', async () => { - // TODO: Test that public client cannot read consent records (RLS should block) + test('cannot read records', async ({ browserName }) => { + skipUnlessChromium(browserName) + const { data: inserted } = await supabaseAdminClient! + .from(SUPABASE_RLS_TABLE) + .insert(buildConsentRecordPayload()) + .select() + .single() + createdRecordIds.push(inserted!.id) + + const { data, error } = await supabaseAnonClient! + .from(SUPABASE_RLS_TABLE) + .select('*') + .eq('id', inserted!.id) + .maybeSingle() + + expectRlsFailure({ error, data }) }) - test.skip('cannot insert records', async () => { - // TODO: Test that public client cannot insert consent records + test('cannot insert records', async ({ browserName }) => { + skipUnlessChromium(browserName) + const { error } = await supabaseAnonClient! + .from(SUPABASE_RLS_TABLE) + .insert(buildConsentRecordPayload()) + + expectRlsFailure({ error }) }) - test.skip('cannot update records', async () => { - // TODO: Test that public client cannot update consent records + test('cannot update records', async ({ browserName }) => { + skipUnlessChromium(browserName) + const insertPayload = buildConsentRecordPayload() + const { data: inserted } = await supabaseAdminClient! + .from(SUPABASE_RLS_TABLE) + .insert(insertPayload) + .select() + .single() + createdRecordIds.push(inserted!.id) + + const { error } = await supabaseAnonClient! + .from(SUPABASE_RLS_TABLE) + .update(Object.fromEntries([[ 'consent_text', 'anon cannot edit' ]])) + .eq('id', inserted!.id) + + expectRlsFailure({ error }) }) - test.skip('cannot delete records', async () => { - // TODO: Test that public client cannot delete consent records + test('cannot delete records', async ({ browserName }) => { + skipUnlessChromium(browserName) + const insertPayload = buildConsentRecordPayload() + const { data: inserted } = await supabaseAdminClient! + .from(SUPABASE_RLS_TABLE) + .insert(insertPayload) + .select() + .single() + createdRecordIds.push(inserted!.id) + + const { error } = await supabaseAnonClient! + .from(SUPABASE_RLS_TABLE) + .delete() + .eq('id', inserted!.id) + + expectRlsFailure({ error }) }) }) }) + diff --git a/test/e2e/specs/11-regression/hero-animation-mobile-menu-pause.spec.ts b/test/e2e/specs/11-regression/hero-animation-mobile-menu-pause.spec.ts index abca342a9..075edc956 100644 --- a/test/e2e/specs/11-regression/hero-animation-mobile-menu-pause.spec.ts +++ b/test/e2e/specs/11-regression/hero-animation-mobile-menu-pause.spec.ts @@ -36,6 +36,7 @@ test.describe('Hero Animation - Mobile Menu Pause Regression', () => { test.beforeEach(async ({ page: playwrightPage }) => { const page = await BasePage.init(playwrightPage) await setupTestPage(page.page, '/') + await page.waitForHeaderComponents() // Wait for hero animation to load await page.waitForSelector('#heroAnimation', { timeout: 5000 }) }) @@ -238,6 +239,7 @@ test.describe('Hero Animation - Mobile Menu Pause Regression', () => { // Wait for navigation await page.waitForLoadState('networkidle') + await page.waitForHeaderComponents() // Menu should close after navigation const hamburgerAfterNav = page.locator('.nav-toggle-btn').first() diff --git a/test/e2e/specs/12-component-persistence/body-visibility.spec.ts b/test/e2e/specs/12-component-persistence/body-visibility.spec.ts index 3e27a4af5..7fb290711 100644 --- a/test/e2e/specs/12-component-persistence/body-visibility.spec.ts +++ b/test/e2e/specs/12-component-persistence/body-visibility.spec.ts @@ -18,11 +18,13 @@ test.describe('View Transitions - body visibility reset', () => { await page.goto('/') await page.waitForLoadState('networkidle') + await page.waitForHeaderComponents() const astroBeforeSwapLog = page.consoleMssgPromise('Theme init on "astro:before-swap" executed') await page.navigateToPage('/about') await page.waitForPageLoad() + await page.waitForHeaderComponents() const logMessage = await astroBeforeSwapLog expect(logMessage.text()).toContain('Theme init on "astro:before-swap" executed') diff --git a/test/e2e/specs/12-component-persistence/head.spec.ts b/test/e2e/specs/12-component-persistence/head.spec.ts index 08c78a923..1d7a6d4e9 100644 --- a/test/e2e/specs/12-component-persistence/head.spec.ts +++ b/test/e2e/specs/12-component-persistence/head.spec.ts @@ -13,6 +13,18 @@ import { ComponentPersistencePage, test, describe, expect } from '@test/e2e/helpers' +const navigateAndAwaitHydration = async ( + page: ComponentPersistencePage, + href: string, + urlPattern: string | RegExp, + timeout = 5000 +) => { + await page.navigateToPage(href) + await page.waitForPageLoad() + await page.waitForHeaderComponents() + await page.waitForURL(urlPattern, { timeout }) +} + describe('View Transitions - transition:persist on meta theme-color', () => { test('should persist meta theme-color element across navigation', async ({ page: playwrightPage, @@ -20,6 +32,7 @@ describe('View Transitions - transition:persist on meta theme-color', () => { const page = await ComponentPersistencePage.init(playwrightPage) await page.goto('/') + await page.waitForHeaderComponents() // Set up test data on the meta theme-color element const initialData = await page.setupPersistenceTest('meta[name="theme-color"]') @@ -33,8 +46,7 @@ describe('View Transitions - transition:persist on meta theme-color', () => { expect(initialContent).toMatch(/^#[0-9a-fA-F]{6}$/) // Navigate to a different page using Astro's View Transitions - await page.navigateToPage('/articles') - await page.waitForURL('**/articles', { timeout: 5000 }) + await navigateAndAwaitHydration(page, '/articles', '**/articles') // Verify the element persisted with the same DOM identity const afterNavigationData = await page.verifyPersistence('meta[name="theme-color"]') @@ -56,6 +68,7 @@ describe('View Transitions - transition:persist on meta theme-color', () => { }) => { const page = await ComponentPersistencePage.init(playwrightPage) await page.goto('/') + await page.waitForHeaderComponents() // Mark the element to verify it persists (Astro behavior) const headData = await page.evaluate(() => { @@ -89,8 +102,7 @@ describe('View Transitions - transition:persist on meta theme-color', () => { expect(metaData.content).toMatch(/^#[0-9a-fA-F]{6}$/) // Navigate using View Transitions - await page.navigateToPage('/articles') - await page.waitForURL('**/articles', { timeout: 5000 }) + await navigateAndAwaitHydration(page, '/articles', '**/articles') // Check if head element persisted (Astro keeps the same head element) const afterNavigationHead = await page.evaluate(() => { @@ -128,6 +140,7 @@ describe('View Transitions - transition:persist on meta theme-color', () => { }) => { const page = await ComponentPersistencePage.init(playwrightPage) await page.goto('/') + await page.waitForHeaderComponents() // Get initial theme-color value const initialColor = await page.evaluate(() => { @@ -138,8 +151,7 @@ describe('View Transitions - transition:persist on meta theme-color', () => { expect(initialColor).toMatch(/^#[0-9a-fA-F]{6}$/) // Navigate to another page - await page.navigateToPage('/services') - await page.waitForURL('**/services', { timeout: 5000 }) + await navigateAndAwaitHydration(page, '/services', '**/services') // Get theme-color value after navigation const afterNavigationColor = await page.evaluate(() => { @@ -154,6 +166,7 @@ describe('View Transitions - transition:persist on meta theme-color', () => { // Home is not listed in the navigation menu, so reload the page to return await page.goto('/') await page.waitForURL(/\/$/, { timeout: 5000 }) + await page.waitForHeaderComponents() // Verify theme-color is still the same const finalColor = await page.evaluate(() => { @@ -170,6 +183,7 @@ describe('View Transitions - transition:persist on meta theme-color', () => { }) => { const page = await ComponentPersistencePage.init(playwrightPage) await page.goto('/') + await page.waitForHeaderComponents() // Mark the canonical link element to check if it's replaced or just updated const initialData = await page.evaluate(() => { @@ -190,8 +204,7 @@ describe('View Transitions - transition:persist on meta theme-color', () => { expect(initialData.href).toMatch(/\/$/) // Navigate to articles page - await page.navigateToPage('/articles') - await page.waitForURL('**/articles', { timeout: 5000 }) + await navigateAndAwaitHydration(page, '/articles', '**/articles') // Check if element was replaced or just updated const afterNavigationData = await page.evaluate(() => { @@ -225,6 +238,7 @@ describe('View Transitions - transition:persist on meta theme-color', () => { }) => { const page = await ComponentPersistencePage.init(playwrightPage) await page.goto('/') + await page.waitForHeaderComponents() // Get initial sitemap URL const initialSitemap = await page.evaluate(() => { @@ -235,8 +249,7 @@ describe('View Transitions - transition:persist on meta theme-color', () => { expect(initialSitemap).toBe('/sitemap-index.xml') // Navigate to services page - await page.navigateToPage('/services') - await page.waitForURL('**/services', { timeout: 5000 }) + await navigateAndAwaitHydration(page, '/services', '**/services') // Get sitemap URL after navigation const afterNavigationSitemap = await page.evaluate(() => { @@ -254,6 +267,7 @@ describe('View Transitions - transition:persist on meta theme-color', () => { }) => { const page = await ComponentPersistencePage.init(playwrightPage) await page.goto('/') + await page.waitForHeaderComponents() // Get initial mobile-web-app-capable value const initialValue = await page.evaluate(() => { @@ -264,8 +278,7 @@ describe('View Transitions - transition:persist on meta theme-color', () => { expect(initialValue).toBe('yes') // Navigate to case studies page - await page.navigateToPage('/case-studies') - await page.waitForURL('**/case-studies', { timeout: 5000 }) + await navigateAndAwaitHydration(page, '/case-studies', '**/case-studies') // Get mobile-web-app-capable value after navigation const afterNavigationValue = await page.evaluate(() => { diff --git a/test/e2e/specs/14-system/package-release.spec.ts b/test/e2e/specs/14-system/package-release.spec.ts index c99015a3d..880e43114 100644 --- a/test/e2e/specs/14-system/package-release.spec.ts +++ b/test/e2e/specs/14-system/package-release.spec.ts @@ -10,6 +10,7 @@ import { test, expect } from '@test/e2e/helpers' import { BasePage } from '@test/e2e/helpers/pageObjectModels/BasePage' +/* eslint-disable import/no-unresolved */ test.describe('Package Release Integration', () => { test('should expose PACKAGE_RELEASE_VERSION in import.meta.env', async ({ page: playwrightPage, @@ -73,7 +74,7 @@ test.describe('Package Release Integration', () => { }) // Get release from privacy page - await page.goto('/privacy/') + await page.goto('/privacy') await page.waitForLoadState('networkidle') const releaseFromPrivacy = await page.page.evaluate(async () => { @@ -105,3 +106,4 @@ test.describe('Package Release Integration', () => { expect(releaseFromModule).toMatch(/^.+@\d+\.\d+\.\d+$/) }) }) +/* eslint-enable import/no-unresolved */ diff --git a/test/e2e/specs/14-system/privacy-policy-version.spec.ts b/test/e2e/specs/14-system/privacy-policy-version.spec.ts index c3761dfc1..099444de6 100644 --- a/test/e2e/specs/14-system/privacy-policy-version.spec.ts +++ b/test/e2e/specs/14-system/privacy-policy-version.spec.ts @@ -10,6 +10,7 @@ import { test, expect } from '@test/e2e/helpers' import { BasePage } from '@test/e2e/helpers/pageObjectModels/BasePage' +/* eslint-disable import/no-unresolved */ test.describe('Privacy Policy Version Integration', () => { test('should expose PRIVACY_POLICY_VERSION in import.meta.env', async ({ page: playwrightPage, @@ -71,7 +72,7 @@ test.describe('Privacy Policy Version Integration', () => { }) // Get version from privacy page - await page.goto('/privacy/') + await page.goto('/privacy') await page.waitForLoadState('networkidle') const versionFromPrivacy = await page.page.evaluate(async () => { @@ -103,3 +104,4 @@ test.describe('Privacy Policy Version Integration', () => { expect(versionFromModule).toMatch(/^\d{4}-\d{2}-\d{2}$/) }) }) +/* eslint-enable import/no-unresolved */ diff --git a/test/e2e/specs/15-cron/cron.spec.ts b/test/e2e/specs/15-cron/cron.spec.ts new file mode 100644 index 000000000..34667c5cc --- /dev/null +++ b/test/e2e/specs/15-cron/cron.spec.ts @@ -0,0 +1,262 @@ +import { randomUUID } from 'node:crypto' +import { createClient, type SupabaseClient } from '@supabase/supabase-js' +import { expect, mocksEnabled, test } from '@test/e2e/helpers' +/** + * These env helpers are safe to use in E2E test as they call process.env + * directly. Must use "npm run dev:env" for this test case to pass. + */ +import { + getCronSecret, + getSuprabaseApiUrl, + getSuprabaseApiServiceRoleKey, + getUpstashApiUrl, + getUpstashApiToken, + } from '@pages/api/_environment/environmentApi' + +const CRON_SECRET = getCronSecret() +const SUPABASE_URL = getSuprabaseApiUrl() +const SUPABASE_SERVICE_ROLE_KEY = getSuprabaseApiServiceRoleKey() +const UPSTASH_URL = getUpstashApiUrl() +const UPSTASH_TOKEN = getUpstashApiToken() + +const requiredEnvMissing = () => { + if (!mocksEnabled) { + return 'E2E_MOCKS=1 is required for cron API integration tests' + } + if (!CRON_SECRET) { + return 'CRON_SECRET must be configured to call cron endpoints' + } + if (!SUPABASE_URL || !SUPABASE_SERVICE_ROLE_KEY) { + return 'Supabase admin credentials are required to seed cron fixtures' + } + if (!UPSTASH_URL || !UPSTASH_TOKEN) { + return 'Upstash REST credentials are required for cron ping coverage' + } + return null +} + +const skipReason = requiredEnvMissing() + +const supabaseAdmin: SupabaseClient | null = skipReason + ? null + : createClient(SUPABASE_URL!, SUPABASE_SERVICE_ROLE_KEY!, { + auth: { autoRefreshToken: false, persistSession: false }, + }) + +const upstashCommandEndpoint = UPSTASH_URL ? new URL('/', UPSTASH_URL).toString() : null +const cronAuthHeader = CRON_SECRET ? `Bearer ${CRON_SECRET}` : null + +const skipUnlessChromium = (browserName: string) => { + test.skip(browserName !== 'chromium', 'Cron tests run once via chromium project to avoid duplicates') +} + +const dayInMs = 24 * 60 * 60 * 1000 + +const createdConfirmationIds = new Set() +const createdDsarIds = new Set() + +const queueCleanup = (bucket: Set, id: string) => { + bucket.add(id) + return id +} + +const cleanupRecords = async (table: 'newsletter_confirmations' | 'dsar_requests', ids: Set) => { + if (!supabaseAdmin || ids.size === 0) { + return + } + const values = Array.from(ids) + await supabaseAdmin.from(table).delete().in('id', values) + ids.clear() +} + +const insertNewsletterConfirmation = async (options: { + expiresAt: Date + confirmedAt?: Date | null + createdAt?: Date +}) => { + if (!supabaseAdmin) { + throw new Error('Supabase client unavailable') + } + const payload = { + token: `cron-newsletter-${randomUUID()}`, + email: `cron-newsletter-${Date.now()}@example.com`, + data_subject_id: randomUUID(), + expires_at: options.expiresAt.toISOString(), + confirmed_at: options.confirmedAt ? options.confirmedAt.toISOString() : null, + created_at: (options.createdAt ?? new Date()).toISOString(), + } + + const { data, error } = await supabaseAdmin + .from('newsletter_confirmations') + .insert(payload) + .select('id') + .single() + + if (error || !data) { + throw new Error(`Failed to insert newsletter confirmation: ${error?.message}`) + } + + return queueCleanup(createdConfirmationIds, data.id) +} + +const insertDsarRequest = async (options: { + fulfilledAt?: Date | null + createdAt?: Date +}) => { + if (!supabaseAdmin) { + throw new Error('Supabase client unavailable') + } + const payload = { + token: `cron-dsar-${randomUUID()}`, + email: `cron-dsar-${Date.now()}@example.com`, + request_type: 'DELETE', + expires_at: new Date(Date.now() + dayInMs).toISOString(), + fulfilled_at: options.fulfilledAt ? options.fulfilledAt.toISOString() : null, + created_at: (options.createdAt ?? new Date()).toISOString(), + } + + const { data, error } = await supabaseAdmin + .from('dsar_requests') + .insert(payload) + .select('id') + .single() + + if (error || !data) { + throw new Error(`Failed to insert DSAR request: ${error?.message}`) + } + + return queueCleanup(createdDsarIds, data.id) +} + +const expectMissingById = async (table: 'newsletter_confirmations' | 'dsar_requests', id: string) => { + if (!supabaseAdmin) { + throw new Error('Supabase client unavailable') + } + const { data, error } = await supabaseAdmin + .from(table) + .select('id') + .eq('id', id) + .maybeSingle() + + if (error) { + throw new Error(`Failed to query ${table}: ${error.message}`) + } + + expect(data).toBeNull() +} + +const setUpstashKeepAlive = async (value: string) => { + if (!upstashCommandEndpoint) { + throw new Error('Missing Upstash endpoint') + } + const response = await fetch(upstashCommandEndpoint, { + method: 'POST', + headers: { + Authorization: `Bearer ${UPSTASH_TOKEN!}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(['SET', '__cron_keepalive__', value]), + }) + + if (!response.ok) { + const body = await response.text().catch(() => 'Unable to read response body') + throw new Error(`Failed to seed Upstash: ${response.status} ${body}`) + } +} + +test.describe('Cron API endpoints @ready', () => { + test.describe.configure({ mode: 'serial' }) + + if (skipReason) { + test.skip(true, skipReason) + } + + test.afterEach(async () => { + await cleanupRecords('newsletter_confirmations', createdConfirmationIds) + await cleanupRecords('dsar_requests', createdDsarIds) + }) + + test('@ready cleanup-confirmations removes expired and stale rows', async ({ browserName, request }) => { + skipUnlessChromium(browserName) + + const now = Date.now() + const expiredId = await insertNewsletterConfirmation({ + expiresAt: new Date(now - 60 * 60 * 1000), + }) + const staleId = await insertNewsletterConfirmation({ + expiresAt: new Date(now + dayInMs), + confirmedAt: new Date(now - 8 * dayInMs), + createdAt: new Date(now - 8 * dayInMs), + }) + + const response = await request.get('/api/cron/cleanup-confirmations', { + headers: { + authorization: cronAuthHeader!, + }, + }) + + expect(response.ok()).toBeTruthy() + const body = (await response.json()) as { + deleted: { expired: number; oldConfirmed: number; total: number } + } + + expect(body.deleted.expired).toBeGreaterThanOrEqual(1) + expect(body.deleted.oldConfirmed).toBeGreaterThanOrEqual(1) + + await expectMissingById('newsletter_confirmations', expiredId) + await expectMissingById('newsletter_confirmations', staleId) + }) + + test('@ready cleanup-dsar-requests prunes fulfilled and expired items', async ({ browserName, request }) => { + skipUnlessChromium(browserName) + + const now = Date.now() + const fulfilledId = await insertDsarRequest({ + fulfilledAt: new Date(now - 31 * dayInMs), + createdAt: new Date(now - 31 * dayInMs), + }) + const expiredPendingId = await insertDsarRequest({ + fulfilledAt: null, + createdAt: new Date(now - 8 * dayInMs), + }) + + const response = await request.get('/api/cron/cleanup-dsar-requests', { + headers: { + authorization: cronAuthHeader!, + }, + }) + + expect(response.ok()).toBeTruthy() + const body = (await response.json()) as { + deleted: { fulfilled: number; expired: number; total: number } + } + + expect(body.deleted.fulfilled).toBeGreaterThanOrEqual(1) + expect(body.deleted.expired).toBeGreaterThanOrEqual(1) + + await expectMissingById('dsar_requests', fulfilledId) + await expectMissingById('dsar_requests', expiredPendingId) + }) + + test('@ready ping-integrations touches Upstash and Supabase', async ({ browserName, request }) => { + skipUnlessChromium(browserName) + const sentinel = `keepalive-${randomUUID()}` + await setUpstashKeepAlive(sentinel) + + const response = await request.get('/api/cron/ping-integrations', { + headers: { + authorization: cronAuthHeader!, + }, + }) + + expect(response.ok()).toBeTruthy() + const body = (await response.json()) as { + upstash: { payload: { result?: string | null }; durationMs: number } + supabase: { rowsChecked: number; durationMs: number } + } + + expect(body.upstash.payload?.result).toBe(sentinel) + expect(body.supabase.rowsChecked).toBeGreaterThanOrEqual(0) + expect(body.supabase.durationMs).toBeGreaterThanOrEqual(0) + }) +}) diff --git a/test/eslint/enforce-centralized-events-rule.ts b/test/eslint/enforce-centralized-events-rule.ts index a512ad58d..1772a2ba9 100644 --- a/test/eslint/enforce-centralized-events-rule.ts +++ b/test/eslint/enforce-centralized-events-rule.ts @@ -5,7 +5,7 @@ */ import type { Rule } from 'eslint' -import type { CallExpression } from 'estree' +import type { CallExpression, Node } from 'estree' interface EventConfig { eventName: string @@ -77,26 +77,42 @@ const enforceCentralizedEventsRule: Rule.RuleModule = { // Look for ClassDeclaration or ClassExpression that extends HTMLElement let foundWebComponent = false + const visitorKeys = sourceCode.visitorKeys ?? {} - function checkNode(astNode: any): void { - if (!astNode) return + const isHTMLElementSubclass = (node: Node): boolean => { + if (node.type !== 'ClassDeclaration' && node.type !== 'ClassExpression') { + return false + } - if (astNode.type === 'ClassDeclaration' || astNode.type === 'ClassExpression') { - if ( - astNode.superClass && - astNode.superClass.type === 'Identifier' && - astNode.superClass.name === 'HTMLElement' - ) { - foundWebComponent = true - } + const superClass = node.superClass + return Boolean( + superClass && + superClass.type === 'Identifier' && + superClass.name === 'HTMLElement', + ) + } + + const checkNode = (astNode: Node | null | undefined): void => { + if (!astNode || foundWebComponent) return + + if (isHTMLElementSubclass(astNode)) { + foundWebComponent = true + return } - // Recursively check children - if (astNode.body) { - if (Array.isArray(astNode.body)) { - astNode.body.forEach(checkNode) - } else { - checkNode(astNode.body) + const keys = visitorKeys[astNode.type] ?? [] + for (const key of keys) { + const value = (astNode as unknown as Record)[key] + if (Array.isArray(value)) { + for (const child of value) { + if (child && typeof child === 'object') { + checkNode(child as Node) + if (foundWebComponent) return + } + } + } else if (value && typeof value === 'object') { + checkNode(value as Node) + if (foundWebComponent) return } } }