Compare commits

..

No commits in common. "main" and "chore/serena-config-update" have entirely different histories.

72 changed files with 506 additions and 1545 deletions

View file

@ -21,16 +21,16 @@ jobs:
playwright: playwright:
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: vitest needs: vitest
services:
mongo:
image: mongo:7
ports:
- 27017:27017
env: env:
MONGODB_URI: mongodb://mongo-ci:27017/ghostguild-test MONGODB_URI: mongodb://localhost:27017/ghostguild-test
JWT_SECRET: ci-test-jwt-secret JWT_SECRET: ci-test-jwt-secret
RESEND_API_KEY: re_ci_dummy_not_used
HELCIM_API_TOKEN: helcim_ci_dummy_not_used
OIDC_COOKIE_SECRET: ci-oidc-cookie-secret-not-secret
NUXT_PUBLIC_COMING_SOON: 'false' NUXT_PUBLIC_COMING_SOON: 'false'
NODE_ENV: development NODE_ENV: development
ALLOW_DEV_TEST_ENDPOINTS: 'true'
BASE_URL: http://localhost:3000
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
@ -39,35 +39,15 @@ jobs:
cache: npm cache: npm
- run: npm ci - run: npm ci
- run: npx playwright install --with-deps chromium - run: npx playwright install --with-deps chromium
- name: Start MongoDB
run: |
docker rm -f mongo-ci 2>/dev/null || true
docker run -d --name mongo-ci mongo:7
# Forgejo runs each job inside its own container; attach Mongo to
# that container's network so MONGODB_URI=mongodb://mongo-ci:27017
# resolves from inside the runner.
RUNNER_NET=$(docker inspect "$HOSTNAME" --format '{{range $k,$v := .NetworkSettings.Networks}}{{$k}} {{end}}' | awk '{print $1}')
docker network connect "$RUNNER_NET" mongo-ci
docker ps
- name: Wait for MongoDB
run: timeout 30 sh -c 'until docker exec mongo-ci mongosh --quiet --eval "1" >/dev/null 2>&1; do sleep 1; done'
- name: MongoDB log on failure
if: failure()
run: docker logs mongo-ci || true
- name: Seed test data
run: node scripts/seed-all.js && node scripts/seed-tags.js
- run: npm run build - run: npm run build
- name: Start server - name: Start server
run: node .output/server/index.mjs > /tmp/server.log 2>&1 & run: node .output/server/index.mjs &
env: env:
PORT: 3000 PORT: 3000
- name: Wait for server - name: Wait for server
run: timeout 30 sh -c 'until curl -sf http://localhost:3000; do sleep 1; done' run: timeout 30 sh -c 'until curl -sf http://localhost:3000; do sleep 1; done'
- name: Server log on failure - run: npx playwright test --ignore-snapshots
if: failure() - uses: actions/upload-artifact@v4
run: cat /tmp/server.log || true
- run: npx playwright test
- uses: actions/upload-artifact@v3
if: failure() if: failure()
with: with:
name: playwright-report name: playwright-report
@ -88,3 +68,39 @@ jobs:
-H 'Content-type: application/json' \ -H 'Content-type: application/json' \
--data "{\"text\":\":x: *Ghost Guild CI failed* on \`${{ github.ref_name }}\`\nCommit: ${{ github.sha }}\n${{ github.server_url }}/${{ github.repository }}/actions\"}" --data "{\"text\":\":x: *Ghost Guild CI failed* on \`${{ github.ref_name }}\`\nCommit: ${{ github.sha }}\n${{ github.server_url }}/${{ github.repository }}/actions\"}"
visual:
runs-on: ubuntu-latest
needs: vitest
continue-on-error: true
services:
mongo:
image: mongo:7
ports:
- 27017:27017
env:
MONGODB_URI: mongodb://localhost:27017/ghostguild-test
JWT_SECRET: ci-test-jwt-secret
NUXT_PUBLIC_COMING_SOON: 'false'
NODE_ENV: development
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- run: npx playwright install --with-deps chromium
- run: npm run build
- name: Start server
run: node .output/server/index.mjs &
env:
PORT: 3000
- name: Wait for server
run: timeout 30 sh -c 'until curl -sf http://localhost:3000; do sleep 1; done'
- run: npx playwright test e2e/visual/
- uses: actions/upload-artifact@v4
if: failure()
with:
name: visual-diffs
path: e2e/test-results/
retention-days: 7

View file

@ -27,10 +27,7 @@
--text: #2a2015; --text: #2a2015;
--text-bright: #1a1008; --text-bright: #1a1008;
--text-dim: #5a5040; --text-dim: #5a5040;
/* Darkened from #746a58 (4.01:1 on --surface, fails WCAG AA) to #665c4b --text-faint: #746a58;
(4.94:1 on --surface, 5.13:1 on --bg). Stays visually quieter than
--text-dim (5.80:1) while meeting AA for small text. */
--text-faint: #665c4b;
--parch: #2a2015; --parch: #2a2015;
--parch-hover: #3a3025; --parch-hover: #3a3025;
--parch-text: #ede4d0; --parch-text: #ede4d0;

View file

@ -178,8 +178,7 @@ const slackLinks = computed(() => {
font-size: 10px; font-size: 10px;
letter-spacing: 0.1em; letter-spacing: 0.1em;
text-transform: uppercase; text-transform: uppercase;
/* --text-faint fails WCAG AA (4.01:1) on the cream card bg */ color: var(--text-faint);
color: var(--text-dim);
} }
.post-actions { .post-actions {
@ -234,8 +233,7 @@ const slackLinks = computed(() => {
font-size: 10px; font-size: 10px;
letter-spacing: 0.1em; letter-spacing: 0.1em;
text-transform: uppercase; text-transform: uppercase;
/* --text-faint fails WCAG AA (4.01:1) on the cream card bg */ color: var(--text-faint);
color: var(--text-dim);
margin-bottom: 2px; margin-bottom: 2px;
} }
.block-text { .block-text {
@ -246,8 +244,7 @@ const slackLinks = computed(() => {
.post-note { .post-note {
font-size: 11px; font-size: 11px;
/* --text-faint fails WCAG AA (4.01:1) on the cream card bg */ color: var(--text-faint);
color: var(--text-dim);
font-style: italic; font-style: italic;
margin: 8px 0; margin: 8px 0;
white-space: pre-wrap; white-space: pre-wrap;
@ -296,8 +293,7 @@ const slackLinks = computed(() => {
align-items: center; align-items: center;
justify-content: center; justify-content: center;
font-size: 10px; font-size: 10px;
/* --text-faint fails WCAG AA (4.01:1) on the cream card bg */ color: var(--text-faint);
color: var(--text-dim);
font-family: "Commit Mono", monospace; font-family: "Commit Mono", monospace;
} }
.author-name { .author-name {
@ -312,8 +308,7 @@ const slackLinks = computed(() => {
} }
.slack-handle { .slack-handle {
font-size: 11px; font-size: 11px;
/* --text-faint fails WCAG AA (4.01:1) on the cream card bg */ color: var(--text-faint);
color: var(--text-dim);
font-family: "Commit Mono", monospace; font-family: "Commit Mono", monospace;
background: transparent; background: transparent;
border: none; border: none;

View file

@ -77,7 +77,12 @@
<input <input
:value="modelValue.alt || ''" :value="modelValue.alt || ''"
placeholder="Describe this image..." placeholder="Describe this image..."
class="w-full px-3 py-2 alt-text-input" class="w-full px-3 py-2"
style="
background: var(--input-bg);
border: 1px solid var(--border);
color: var(--text);
"
@input="updateAltText($event.target.value)" @input="updateAltText($event.target.value)"
> >
</div> </div>
@ -220,16 +225,3 @@ const updateAltText = (altText) => {
}); });
}; };
</script> </script>
<style scoped>
.alt-text-input {
background: var(--input-bg);
border: 1px solid var(--border);
color: var(--text);
}
.alt-text-input:focus {
outline: none;
border-color: var(--candle);
}
</style>

View file

@ -1,8 +0,0 @@
export const STATUS_LABELS = {
active: "Active",
pending_payment: "Payment setup incomplete",
suspended: "Paused",
cancelled: "Closed",
};
export const statusLabel = (s) => STATUS_LABELS[s] || "Pending";

View file

@ -21,15 +21,6 @@ export default defineNuxtRouteMiddleware(async (to, from) => {
return; return;
} }
// Logged-in admins bypass coming-soon (and see the public site + their dashboard)
try {
const headers = import.meta.server ? useRequestHeaders(["cookie"]) : undefined;
const member = await $fetch("/api/auth/member", { headers });
if (member?.role === "admin") return;
} catch {
// Not authenticated — fall through to redirect
}
// Redirect all other routes to coming-soon // Redirect all other routes to coming-soon
return navigateTo("/coming-soon"); return navigateTo("/coming-soon");
}); });

View file

@ -63,11 +63,10 @@
<div class="field"> <div class="field">
<label>Status</label> <label>Status</label>
<select v-model="form.status"> <select v-model="form.status">
<option <option value="pending_payment">pending_payment</option>
v-for="(label, value) in STATUS_LABELS" <option value="active">active</option>
:key="value" <option value="suspended">suspended</option>
:value="value" <option value="cancelled">cancelled</option>
>{{ label }}</option>
</select> </select>
</div> </div>
<div class="field"> <div class="field">
@ -243,7 +242,6 @@
<script setup> <script setup>
import { formatActivity } from '~/utils/activityText' import { formatActivity } from '~/utils/activityText'
import { STATUS_LABELS } from '~/config/memberStatus'
definePageMeta({ definePageMeta({
layout: "admin", layout: "admin",

View file

@ -41,11 +41,10 @@
<div class="field" style="margin-bottom: 0"> <div class="field" style="margin-bottom: 0">
<select v-model="statusFilter" aria-label="Filter by status"> <select v-model="statusFilter" aria-label="Filter by status">
<option value="">All Statuses</option> <option value="">All Statuses</option>
<option <option value="active">Active</option>
v-for="(label, value) in STATUS_LABELS" <option value="pending_payment">Payment setup incomplete</option>
:key="value" <option value="suspended">Suspended</option>
:value="value" <option value="cancelled">Cancelled</option>
>{{ label }}</option>
</select> </select>
</div> </div>
</div> </div>
@ -372,11 +371,10 @@
<div class="field"> <div class="field">
<label>Status</label> <label>Status</label>
<select v-model="editingMember.status"> <select v-model="editingMember.status">
<option <option value="pending_payment">Payment setup incomplete</option>
v-for="(label, value) in STATUS_LABELS" <option value="active">Active</option>
:key="value" <option value="suspended">Suspended</option>
:value="value" <option value="cancelled">Cancelled</option>
>{{ label }}</option>
</select> </select>
</div> </div>
<div class="modal-actions"> <div class="modal-actions">
@ -468,8 +466,6 @@
</template> </template>
<script setup> <script setup>
import { STATUS_LABELS, statusLabel } from "~/config/memberStatus";
definePageMeta({ definePageMeta({
layout: "admin", layout: "admin",
middleware: "admin", middleware: "admin",
@ -490,6 +486,14 @@ const statusFilter = ref("");
const sortKey = ref("createdAt"); const sortKey = ref("createdAt");
const sortDir = ref("desc"); const sortDir = ref("desc");
const STATUS_LABELS = {
active: "Active",
pending_payment: "Payment setup incomplete",
suspended: "Suspended",
cancelled: "Cancelled",
};
const statusLabel = (s) => STATUS_LABELS[s] || "Pending";
const toggleSort = (key) => { const toggleSort = (key) => {
if (sortKey.value === key) { if (sortKey.value === key) {
sortDir.value = sortDir.value === "asc" ? "desc" : "asc"; sortDir.value = sortDir.value === "asc" ? "desc" : "asc";
@ -839,8 +843,7 @@ const markSlackInvited = async (member) => {
body: { slackInvited: true }, body: { slackInvited: true },
}, },
); );
const idx = members.value.findIndex((m) => m._id === member._id); Object.assign(member, res.member);
if (idx !== -1) members.value[idx] = { ...members.value[idx], ...res.member };
toast.add({ title: "Marked as Slack invited", color: "success" }); toast.add({ title: "Marked as Slack invited", color: "success" });
} catch (err) { } catch (err) {
toast.add({ toast.add({

View file

@ -232,12 +232,8 @@ const isAlmostFull = (event) => {
.event-row:hover { .event-row:hover {
padding-left: 4px; padding-left: 4px;
} }
.event-row.is-cancelled .event-title a { .event-row.is-cancelled {
text-decoration: line-through; opacity: 0.5;
text-decoration-thickness: 1px;
}
.event-row.is-cancelled .event-tagline {
text-decoration: line-through;
} }
.event-date-col { .event-date-col {

View file

@ -317,17 +317,13 @@
<ParchmentInset> <ParchmentInset>
<h2>How membership works</h2> <h2>How membership works</h2>
<ul> <ul>
<li>Full access to the knowledge commons, events and workshops, and community</li> <li>Full access to the knowledge commons, Slack, and peer support</li>
<li>Free access to all Ghost Guild events</li> <li>Free access to all Ghost Guild events</li>
<li>Equal access for every member, regardless of contribution</li> <li>Equal access for every member, regardless of contribution</li>
<li>Your circle reflects where you are, not rank</li> <li>Your circle reflects where you are, not rank</li>
<li>Pay what you can ($0&ndash;$50+/month, separate from circle)</li> <li>Pay what you can ($0&ndash;$50+/month, separate from circle)</li>
<li>Higher contributions create solidarity spots for others</li> <li>Higher contributions create solidarity spots for others</li>
</ul> </ul>
<p>
Community connection happens in our Slack workspace, joined in monthly
onboarding waves &mdash; there may be a short wait after you join.
</p>
</ParchmentInset> </ParchmentInset>
<!-- THREE CIRCLES --> <!-- THREE CIRCLES -->

View file

@ -315,7 +315,6 @@
<script setup> <script setup>
import { CONTRIBUTION_PRESETS, getGuidanceLabel, requiresPayment } from '~/config/contributions'; import { CONTRIBUTION_PRESETS, getGuidanceLabel, requiresPayment } from '~/config/contributions';
import { STATUS_LABELS } from '~/config/memberStatus';
definePageMeta({ definePageMeta({
middleware: "auth", middleware: "auth",
@ -418,6 +417,13 @@ const circleOptions = [
}, },
]; ];
const STATUS_LABELS = {
active: "Active",
pending_payment: "Setting up payment",
suspended: "Paused",
cancelled: "Closed",
};
const formatStatus = (s) => STATUS_LABELS[s] || s; const formatStatus = (s) => STATUS_LABELS[s] || s;
const capitalise = (s) => (s ? s.charAt(0).toUpperCase() + s.slice(1) : s); const capitalise = (s) => (s ? s.charAt(0).toUpperCase() + s.slice(1) : s);

View file

@ -39,8 +39,8 @@
<span>${{ memberData?.contributionAmount ?? 0 }} CAD/mo</span> <span>${{ memberData?.contributionAmount ?? 0 }} CAD/mo</span>
</div> </div>
<p v-if="showSlackComingNote" class="slack-coming-note"> <p v-if="showSlackComingNote" class="slack-coming-note">
Slack workspace access is part of your membership. Invitations are Slack workspace access is part of your membership. Your invitation
sent in monthly onboarding waves &mdash; we'll be in touch. typically arrives within 2&ndash;3 weeks of joining.
</p> </p>
</PageHeader> </PageHeader>

View file

@ -1,122 +0,0 @@
# Ghost Guild — Open Backlog
_Last consolidated: 2026-04-30. Single source of truth for every open issue across the codebase. Pulls from `LAUNCH_READINESS.md`, `TODO.md`, the post-launch backlog memory, and a fresh sweep of in-code TODO/FIXME comments._
Cutover has not happened yet. Deploy steps live separately in [`LAUNCH_READINESS.md`](./LAUNCH_READINESS.md).
---
## Pre-cutover (do once)
Operational steps that have to run during cutover. Full details + env-var list in [`LAUNCH_READINESS.md`](./LAUNCH_READINESS.md).
- [ ] Provision the Dokploy app, set env vars (full list in LAUNCH_READINESS.md), confirm `BASE_URL` exact-matches the public origin and `NODE_ENV=production`.
- [ ] Add the daily Dokploy Scheduled Task that POSTs to `/api/internal/reconcile-payments` with `X-Reconcile-Token`.
- [ ] **Run `node scripts/migrate-contribution-amount.cjs --apply` against prod Mongo BEFORE the new code serves traffic.**
- [ ] Set `NUXT_HELCIM_MONTHLY_PLAN_ID=50302` and `NUXT_HELCIM_ANNUAL_PLAN_ID=50303` in Dokploy.
- [ ] Set `NUXT_RECONCILE_TOKEN` to a 32+ char random string.
- [ ] Push local `main` to `origin/main`.
- [ ] Deploy.
- [ ] **Run `node scripts/reconcile-helcim-payments.mjs --apply` against prod Mongo AFTER the new code serves traffic.**
- [ ] Audit prod for pre-fix series-pass bypass registrations (registrations on pass-only series children with `registeredAt < 2026-04-20` from non-pass-holders). Decide per case.
- [ ] In Helcim dashboard: disable the default payment-confirmation email for plans 50302 + 50303 (we send our own CRA-safe version via Resend).
- [ ] Run one real test charge and verify (a) Payment doc in Mongo and (b) exactly one CRA-compliant confirmation email.
- [ ] Rotate `HELCIM_API_TOKEN` in the Helcim merchant portal and update the Dokploy env var.
- [ ] Trigger the daily reconcile task once manually in Dokploy to confirm it's wired correctly.
## Pilot smoke walks (before first wave)
Once cutover lands, before the first Slack onboarding wave goes out:
- [ ] **Pilot smoke walk for Slack-invited workflow.** One admin manually clicks "Mark as Slack invited" against a real test member in production, confirms the row updates in place, and confirms the dashboard "Slack coming" note disappears for that member. Unit tests cover the pieces; nothing covers the live admin-to-member round-trip.
---
## Bylaws-decoupling (waiting on amendment ratification)
Membership status is being decoupled from payment status. Copy + UI gates already align; behavioral changes below remain.
- [ ] **B1.** `server/api/members/cancel-subscription.post.js:31,48` flips status to `pending_payment` on cancel. Under the new bylaws, cancellation should keep status `active` (just zero contribution). Update the `findByIdAndUpdate` payload + response, the comment at line 26, and add coverage in `tests/server/api/cancel-subscription.test.js`.
- ~~B3 cancelled.~~ `pending_payment` stays.
- ~~B4 admin "Pending Payment" → "Payment setup incomplete"~~ shipped 2026-04-29 (`59d2be2`).
---
## Known gotchas (post-launch)
- **Admin edit does not sync Helcim `recurringAmount`.** `/admin/members/[id]` PUT writes `contributionAmount` direct to Mongo by design. Admins must PATCH Helcim manually. The admin form already shows an `--ember`-bordered notice (commit `e756170`); a real sync flow is a future enhancement.
- **Cadence switch rejected on active subscriptions.** `server/api/members/update-contribution.post.js:206` refuses cadence changes mid-subscription with a TODO comment pointing here. No UI toggle exists on `/member/account`. Adding cadence switch requires a Helcim subscription replacement flow, not a plain update.
- **S2 test fixture id/slug mismatch (local dev only).** Seeded S2 series has `id: 'test-s2-drop-in-allowed'` but `slug: 'test-s2-drop-in-allowed-series'`. Doesn't affect prod — fix the seed script if anyone re-runs fixtures.
- **`/admin/series-management` "Delete" button doesn't actually delete.** Click handler iterates events to PUT-unlink each from the series, never calls `DELETE /api/admin/series/:id`. For an empty series the button is a no-op; for a series with events it just orphans them. Either rename to "Unlink events" or add the actual DELETE call. Surfaced by `e2e/admin-series.spec.js` (delete test skipped). Flagged 2026-04-30.
- **Past-deadline events and sold-out events render identically.** `EventTicketPurchase.vue` falls through to "Event Sold Out" panel for both `tickets.available.reason === 'Registration deadline has passed'` and zero-stock cases. If "Registration closed" is meant to read differently from "Sold out," add a distinct branch. Flagged 2026-04-30 (no e2e written — gated on this UX decision).
---
## Accessibility / a11y
- [ ] **Button minimum target size.** Site-wide `.btn` renders ~35px tall. WCAG AA 2.5.8 (24×24) passes; AAA 2.5.5 (44×44) fails. Bumping padding affects every button — design call, not a drop-in fix. Flagged 2026-04-11.
- [ ] **`/board` color-contrast violations (WCAG AA).** `.block-label` ("Offering" tag) and `.slack-handle` use `#746a58` on `#e8dfc8` → 4.01:1; AA needs 4.5:1 for small text. Surfaced by `e2e/a11y.spec.js` (the `/board` route fails; test is intentionally left red until fixed). Likely a single CSS variable adjustment. Flagged 2026-04-30.
---
## Deferred features (own session each)
- [ ] **Email automation system.** Patterned after Tranzac's implementation (separate project, already built). HTML email bodies with template management and drip sequences. Deferred 2026-04-20 — ruled wasted work given the larger system is designed elsewhere. Current transactional email lives in `server/utils/resend.js` + inline in `server/api/auth/login.post.js`, `server/routes/oidc/interaction/login.post.ts`, `server/api/admin/{members,pre-registrants}/invite.post.js`. Copy dump at `docs/email-copy-dump.md`. See memory: `project_email_automation_future`.
- [ ] **Receipts for event ticket purchases (Phase 2).** Phase 1 receipts only cover membership payments. Event tickets — especially guest purchases without member accounts — need a receipt flow. Likely an emailed PDF/HTML receipt at purchase time. Build target: JuneOct 2026, live Jan 2027. See memory: `project_receipts`.
- [ ] **Series/event waitlist.** Admin can configure `tickets.waitlist.enabled` and `maxSize`; `server/utils/tickets.js` returns `waitlistAvailable: true` when full; `app/components/SeriesPassPurchase.vue:341` and `EventTicketPurchase.vue` have stub `handleJoinWaitlist` that toasts "Waitlist Coming Soon." No server endpoint, no confirmation email, no `event_waitlisted` activity hook. Either implement end-to-end or hide the button by removing the `v-if="availability?.waitlistAvailable"` branches in `EventSeriesTicketCard.vue:175` and `EventTicketCard.vue:73`.
- [ ] **ASVS Phase 4.** File-upload validation pipeline, granular RBAC, credential encryption.
---
## Wave-Slack pilot follow-ups
- [ ] **`/api/auth/member` doesn't return `slackInvited`.** Dashboard's Slack-coming note is gated on `memberData.slackInvited`, which is always `undefined` client-side, so the note shows for *every* active member regardless of state. Real bug. Add `slackInvited` (and `slackInvitedAt`) to the auth/member response. Surfaced by wave-slack §7.2 e2e (skipped pending this fix). Flagged 2026-04-30.
- [ ] **Admin members list row mutation isn't reactive.** `markSlackInvited` in `app/pages/admin/members/index.vue` does `Object.assign(member, res.member)` on a plain object inside a `useFetch` array; Vue doesn't react, so the "Mark as Slack invited" button stays visible until a manual reload. Fix: `members.value[i] = { ...members.value[i], ...res.member }` or `splice`. Detail page uses the right pattern (covered by §6.6). Surfaced by wave-slack §6.2 e2e (skipped pending this fix). Flagged 2026-04-30.
- [ ] **Deprecated `slackInviteStatus` field still serialized.** Removed from UI but still on `Member` documents and the `/api/admin/members` payload. Project it away in the API response and run a one-shot `$unset` cleanup. Surfaced by wave-slack §6.7 e2e. Flagged 2026-04-30.
- [ ] **Spec vs shipped-UI mismatch on wave language.** `docs/specs/wave-based-slack-onboarding-tests.md` §7.5 asserts "no wave/cohort/batch language" in the dashboard note, but the shipped welcome-email and dashboard copy say "monthly onboarding waves." Decide which side wins; update the other.
- [ ] **E2E coverage for `e2e/wave-slack-onboarding.spec.js`.** 9 of 16 scaffolded tests now passing (admin Slack-invited button + non-trivial dashboard cases). 7 remain skipped pending the bugs above (7.2, 6.2), seeding gaps (7.4 — no dev endpoint to mint members of arbitrary status), Open Questions (7.8, 6.9), or spec-vs-UI conflicts (7.5, 6.7).
- [ ] **Pilot exit decision (~8 weeks post-launch).** Either restore `server/_archive/utils/checkSlackJoins.js` + its plugin if polling is needed, or delete the archive permanently. Driven by whether the manual-invite cadence is sustainable post-pilot.
- [ ] **`slack_invite_failed` enum slug cleanup.** Detector and alert removed in `d15458b`, but the slug remains in `server/models/adminAlertDismissal.js` enum so historical dismissal rows continue to validate. Full removal needs a one-shot cleanup of stale dismissal rows in the DB. Roll into a future schema-tidy pass.
---
## Simplify-pass follow-ups (still open)
Items surfaced during the 2026-04-29 /simplify review. The 2026-04-30 small-wins batch shipped 3 items (STATUS_LABELS dedup, ImageUpload focus, signupBridge rename). Remaining:
- [ ] **Extract `.tint-candle` / `.tint-ember` utility classes.** The `color-mix(in srgb, var(--candle) 15%, transparent)` + matching border pattern is now inlined as `style=""` in ~9 sites across `EventSeriesTicketCard.vue`, `SeriesPassPurchase.vue`, `NaturalDateInput.vue`, `ImageUpload.vue`. Promote to utility classes in `app/assets/css/main.css` so future tints don't keep multiplying inline styles (and so `:hover` / `:focus` variants are reachable).
- [ ] **Audit `member &&` truthy checks in sibling ticket/subscription routes.** Commit `f66455e` fixed `server/api/events/[id]/tickets/available.get.js:115` to use `hasMemberAccess(member)`. Same anti-pattern likely exists in adjacent routes (`tickets/purchase.post.js`, subscription endpoints). Guests/suspended/cancelled members would currently look like full members for any feature gated on truthiness alone.
- [ ] **STATUS_LABELS dedup — verify.** The 2026-04-30 small-wins batch claimed STATUS_LABELS dedup, but `e2e/admin-members.spec.js` expansion found an inline copy still at `app/pages/admin/members/index.vue:491` and another at `app/pages/member/account.vue:420`. Either the previous dedup was partial or a new copy was reintroduced — confirm and finish dedup into a shared constants module.
- [ ] **`app/pages/admin/members/[id].vue` status select still hand-written.** Commit `441a5f5` aligned the index page's status `<select>` to `STATUS_LABELS`, but the detail page (`[id].vue`) still hand-codes raw status options. Refactor to drive from the same constant.
---
## Optional / low-priority
- [ ] **Welcome-email Slack-timing mention.** Currently the welcome email doesn't mention Slack timing — the dashboard carries that note. Could add a one-line "Slack invitation comes in monthly waves — there may be a short wait" if the dashboard turns out not to be enough signal.
---
## E2e infrastructure gaps
Surfaced during the 2026-04-30 e2e expansion. None block a green suite, but each blocks specific coverage from being added.
- [ ] **Other email routes still send real emails in dev mode.** The `ALLOW_DEV_TEST_ENDPOINTS` short-circuit was added to `server/api/admin/pre-registrants/invite.post.js` (which calls `new Resend(...)` directly), but the five wrapper functions in `server/utils/resend.js` (event registration, cancellation, waitlist, series pass, welcome) still dispatch live. Either add the same gate to each wrapper, or refactor the wrappers into a single `sendEmail({ from, to, subject, text, html })` helper holding the gate centrally — would also dedupe ~5 near-identical try/catch blocks.
- [ ] **No dev endpoint to seed members of arbitrary status.** Wave-slack §7.4 (note hidden for suspended/cancelled/guest) is gated on this. `/api/dev/test-login` only mints an `active` admin. A minimal `/api/dev/members.post` accepting `{ email, status, slackInvited, ... }` would unblock many more dashboard-state e2e tests.
- [ ] **SSR `useFetch` blocks `page.route` mocking.** Page-level fetches in `[slug].vue` files run during SSR and can't be intercepted client-side. Affects: hidden-event 404 e2e, any test that needs a mocked event payload before client hydration. Either expose a client-side fetch alternative, add a server-side test mock layer, or accept that DB seeding is required for these cases.
- [ ] **Self-cancel block on paid event registrations not e2e-tested.** Requires seeding a logged-in member with a paid registration row. Out of scope for this round.
- [ ] **Visual snapshot for `join — desktop` is stale.** 12,676px diff (2% of image) from layout drift. Regenerate via `npx playwright test --update-snapshots e2e/visual/pages.spec.js` once a designer eyeballs the diff.
- [ ] **E2e cross-file races on admin specs.** With `fullyParallel: false` + `workers: 4` + `retries: 1`, ~1 admin CRUD test still fails per full-suite run (rotates between `admin-events` CRUD, `board` page-loads, and wave-slack §6.4). Each passes 100% in isolation. Root cause: tests anchor on "first row" / "any visible button" rather than uniquely-identified data, so they race when other admin specs mutate the shared dev DB. Proper fix is per-test data isolation: each test creates its own scoped record with a `Date.now()` suffix and queries by that exact identifier. Out of scope for the e2e expansion.
---
## Deeplink memories
- `project_post_launch_backlog.md` — high-level digest of this file
- `project_launch_readiness.md` — cutover status (NOT YET happened)
- `project_launch_flow_map.md` — onboarding flow + Slack wave model
- `project_pre_registrants.md` — invitation system + pre-reg lifecycle
- `project_helcim_plan_model.md` — cadence-keyed plan model
- `project_contribution_amount_redesign.md` — arbitrary $ amount + guidance presets
- `project_receipts.md` — Phase 1 done, Phase 2 pending
- `project_email_automation_future.md` — Tranzac reference for full system

View file

@ -1,8 +1,8 @@
# Launch Readiness # Launch Readiness
**Status as of 2026-04-30. Cutover has not happened yet.** Code is on local `main`; deploy steps below still need to execute. **Status as of 2026-04-20.** Target launch: before 2026-05-01.
Pre-cutover deploy checklist is the live content on this page. Everything else (post-launch work, bylaws decoupling, deferred features, simplify follow-ups, a11y) lives in [`BACKLOG.md`](./BACKLOG.md). Completed launch-blocker items are archived — see `~/.claude/projects/-Users-jennie-Sites-ghostguild-org/memory/project_launch_readiness_archive.md`. Single source of truth for work remaining before cutover. P0 blocks launch; P1 is strongly preferred but survivable. Completed items are archived — see `~/.claude/projects/-Users-jennie-Sites-ghostguild-org/memory/project_launch_readiness_archive.md`. Post-launch backlog lives in `docs/TODO.md`.
--- ---
@ -106,7 +106,60 @@ None outstanding. All launch-blocking flows verified via local dev or cloudflare
--- ---
## Post-launch & deferred work ## Bylaws decoupling — follow-ups (added 2026-04-18)
Bylaws decoupling, post-launch a11y, ASVS Phase 4, deferred features, simplify-pass follow-ups, known gotchas, wave-Slack pilot follow-ups — **everything that isn't a deploy step has moved to [`BACKLOG.md`](./BACKLOG.md).** Context: bylaws are being amended to remove automatic termination for nonpayment. Membership status will be fully decoupled from payment status; failed payments trigger committee outreach, not status change. Copy + UI access gates already aligned in `useMemberStatus.js` and `account.vue` (2026-04-18). Server-side status gating shipped as B2 (see archive). The behavioral changes below remain.
Not blocking launch — the amendment hasn't passed yet, and the user-visible copy/UI is already consistent. Pick up once the amendment is ratified.
### B1. `cancel-subscription` flips status to `pending_payment`
- `server/api/members/cancel-subscription.post.js:31,48`
- When a member cancels their paid subscription, status is set to `pending_payment` and contribution amount to `0`. Under the new model, cancelling a payment plan moves the member to the $0 contribution — status should stay `active`.
- **Fix:** change `status: 'pending_payment'``status: 'active'` in both the `findByIdAndUpdate` payload (line 31) and the response (line 48). Comment at line 26 also needs updating ("(not cancelled) so member can re-subscribe" → reflect new framing).
- Add coverage in `tests/server/api/cancel-subscription.test.js` if it doesn't already exist.
### B3. Vestigial `pending_payment` status
- Once payment is fully decoupled, `pending_payment` no longer gates anything and is functionally equivalent to `active`. Consider removing it from the enum (`server/models/member.js:38`, `server/utils/schemas.js:299`) and treating new signups as `active` from the moment of account creation.
- Touches: signup flow (`helcim/customer.post.js:34`, `invite/accept.post.js:48`), admin filter UI (`app/pages/admin/members/index.vue:45,382,499,1145`, `[id].vue:69,286`), admin alerts (`server/utils/adminAlerts.js:22,100-116`, `server/models/adminAlertDismissal.js:6`), and a data migration to flip existing `pending_payment` rows to `active`.
- Larger refactor — break out into its own ticket once B1 lands.
### B4. Admin "Pending Payment" filter label (cosmetic)
- `app/pages/admin/members/index.vue:45,499`, `[id].vue:69` show `pending_payment` as "Pending Payment". If B3 removes the status entirely, this disappears too. If we keep `pending_payment` for now, rename in admin UI to "Payment setup incomplete" so admins also stop conflating it with membership state.
---
## Post-launch backlog
See `docs/TODO.md` for:
- Button minimum target size (WCAG AAA 2.5.5).
- ~~`/oidc/interaction/[uid]` routing quirk~~ — fixed 2026-04-29 (commit `23154ff`); root cause was `oidc-provider`'s `devInteractions` overriding our custom `interactions.url`.
- ~~Admin layout migration from `guild-*` tokens to zine spec~~ — verified clean 2026-04-29; grep for `guild-[0-9]|candlelight-[0-9]|ember-[0-9]` across `app/layouts/`, `app/pages/admin/`, `app/components/admin/` returns zero matches. All tokens already converted.
- ~~Admin dashboard quick-action button contrast~~ — verified stale 2026-04-29.
- ~~Members table NAME column clipping~~ — verified stale 2026-04-29.
- OWASP ASVS L1 Phase 4 (file-upload validation pipeline, granular RBAC, credential encryption).
- ~~`tickets/available.get.js:115` `memberSavings` block reports `$0 saved` for inactive members~~ — fixed 2026-04-29 (commit `f66455e`); `memberSavings` now gated on `hasMemberAccess(member)`.
- Simplify-pass follow-ups (2026-04-25): SHIPPED 2026-04-27 on branch `chore/simplify-pass-follow-ups` (pending merge). See `~/.claude/projects/-Users-jennie-Sites-ghostguild-org/memory/project_simplify_pass_2026_04_25.md`.
- ~~Reconcile `customerCode` bug~~ — fixed on `main` in commit `3c38333` ("pass customerCode (not helcimCustomerId) to Helcim transactions API"). Verified in `server/api/internal/reconcile-payments.post.js:97`.
- ~~Drive-by from 2026-04-29 phantom-Tailwind sweep: `app/components/EventSeriesBadge.vue` has zero usages~~ — deleted 2026-04-29 (commit `f85f284`); 81 lines removed.
- Simplify-pass follow-ups (2026-04-29): smallest wins shipped in commit `26791cc`; deferred items (rename `setPaymentBridgeCookie`, dedup admin `STATUS_LABELS`, extract `.tint-candle`/`.tint-ember` utilities, audit `member &&` truthy checks in sibling routes, restore `ImageUpload` alt-text input focus styling) tracked in `docs/TODO.md` § _Simplify-pass follow-ups — 2026-04-29_.
### Known gotchas worth addressing post-launch
- **Admin edit does not sync Helcim `recurringAmount`.** `/admin/members/[id]` PUT writes `contributionAmount` direct to Mongo by design. Admins must PATCH Helcim manually. Worth surfacing in admin UI or docs.
- **Cadence switch rejected on active subscriptions.** `update-contribution.post.js:184-189` refuses cadence changes mid-subscription; no UI toggle exists on `/member/account`. Adding cadence switch would require a Helcim subscription replacement flow, not a plain update.
- **S2 test fixture `id`/`slug` inconsistency.** (Local dev only.) Seeded S2 series has `id: 'test-s2-drop-in-allowed'` but `slug: 'test-s2-drop-in-allowed-series'`. Doesn't affect prod — fix the seed script if anyone re-runs fixtures and is confused why `id`-based Mongo queries return empty.
### Events-surface visual audit — deferred items (2026-04-21)
Context: Phase 4 audit against `docs/specs/events-visual-audit-findings.md` fixed all critical phantom-palette, rounded-corner, CTA-mismatch, and input-styling issues across `EventTicketCard`, `EventTicketPurchase`, `EventSeriesTicketCard`, `SeriesPassPurchase`. Items below were explicitly deferred or out of reach.
- ~~**Success-state color convention (4 instances).**~~ Resolved 2026-04-29: gold (`--candle`) chosen as zine-consistent. Phantom-Tailwind cleanup shipped in `dc2becf` (`EventSeriesTicketCard.vue` + `SeriesPassPurchase.vue` member-benefit notice).
- ~~**Sidebar breakpoint unverified.**~~ Verified clean 2026-04-29 — `.events-mini` hides at ≤1024px cleanly across 1023/1024/1025/1100. Actual rule lives in `EventsMiniSidebar.vue:129` + `ColumnsLayout.vue:83` (audit doc cited the wrong line).
- ~~**`EventTicketPurchase.vue:469` magic padding.**~~ Fixed 2026-04-29 (commit `7e44809`); consent block now uses a grid approach.
- ~~**`.section-label` extraction candidate.**~~ Verified 2026-04-29 — utility already exists at `main.css:128` and is used in 30+ places. Two scoped overrides intentionally diverge.
- ~~**Past-events toggle component.**~~ Audited 2026-04-29 — consistent with the design system (dashed-border button, gold active state, valid `aria-pressed` toggle). Added missing `:focus-visible` outline in commit `dadec1a`; no other changes warranted.
### Contribution-amount redesign — cosmetic cleanup (naming only, not behavior)
SHIPPED 2026-04-29 in commit `955217a` (admin column header, dropdown labels, handler rename, log message).

Binary file not shown.

After

Width:  |  Height:  |  Size: 315 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 167 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 168 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 237 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 232 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 290 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 133 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 201 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 181 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 287 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 282 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 154 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 267 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 204 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 244 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 253 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 282 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 194 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 154 KiB

View file

@ -7,20 +7,16 @@ const publicPages = [
{ name: "Join", path: "/join" }, { name: "Join", path: "/join" },
{ name: "Events", path: "/events" }, { name: "Events", path: "/events" },
{ name: "Coming Soon", path: "/coming-soon" }, { name: "Coming Soon", path: "/coming-soon" },
{ name: "Accept Invite", path: "/accept-invite" },
]; ];
const memberPages = [ const memberPages = [
{ name: "Member Dashboard", path: "/member/dashboard" }, { name: "Member Dashboard", path: "/member/dashboard" },
{ name: "Member Profile", path: "/member/profile" }, { name: "Member Profile", path: "/member/profile" },
{ name: "Member Account", path: "/member/account" },
{ name: "Board", path: "/board" },
]; ];
const adminPages = [ const adminPages = [
{ name: "Admin Members", path: "/admin/members" }, { name: "Admin Members", path: "/admin/members" },
{ name: "Admin Events Create", path: "/admin/events/create" }, { name: "Admin Events Create", path: "/admin/events/create" },
{ name: "Admin Pre-Registrants", path: "/admin/pre-registrants" },
]; ];
test.describe("accessibility — public pages", () => { test.describe("accessibility — public pages", () => {

View file

@ -1,170 +0,0 @@
import { test, expect } from '@playwright/test'
const FAKE_TOKEN = 'fake-invite-token-for-e2e'
const FAKE_PREREG_ID = '000000000000000000000001'
async function mockVerifyOk(page, overrides = {}) {
await page.route('**/api/invite/verify', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
preRegistrationId: FAKE_PREREG_ID,
name: overrides.name ?? 'Pre Registered User',
email: overrides.email ?? `prereg-${Date.now()}@example.com`,
city: overrides.city ?? 'Vancouver, BC',
}),
})
})
}
async function mockAcceptFree(page) {
await page.route('**/api/invite/accept', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
success: true,
requiresPayment: false,
redirectUrl: '/member/dashboard',
member: {
id: 'mem-1',
email: 'prereg@example.com',
name: 'Pre Registered User',
circle: 'community',
contributionAmount: 0,
status: 'active',
},
}),
})
})
await page.route('**/api/auth/status', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
authenticated: true,
member: { id: 'mem-1', name: 'Pre Registered User', status: 'active' },
status: 'active',
}),
})
})
}
async function gotoAcceptInvite(page) {
await page.goto(`/accept-invite#${FAKE_TOKEN}`)
}
test.describe('Accept Invite — pre-registrant signup', () => {
test('verifies invitation and shows form fields', async ({ page }) => {
await mockVerifyOk(page, { name: 'Ada Lovelace', email: 'ada@example.com' })
await gotoAcceptInvite(page)
await expect(page.locator('#accept-name')).toBeVisible()
await expect(page.locator('#accept-name')).toHaveValue('Ada Lovelace')
await expect(page.locator('#accept-email')).toHaveValue('ada@example.com')
await expect(page.locator('#circle-community')).toBeAttached()
await expect(page.locator('#circle-founder')).toBeAttached()
await expect(page.locator('#circle-practitioner')).toBeAttached()
await expect(page.locator('#accept-cadence-monthly')).toBeAttached()
await expect(page.locator('#accept-cadence-annual')).toBeAttached()
await expect(page.locator('#accept-contribution')).toBeVisible()
await expect(page.locator('.contribution-preset-chip').first()).toBeVisible()
await expect(page.locator('.form-submit')).toBeVisible()
})
test('shows error when no token in URL hash', async ({ page }) => {
await page.goto('/accept-invite')
await expect(page.getByRole('heading', { name: 'Invitation Error' })).toBeVisible()
await expect(page.locator('.error-box')).toContainText(/No invitation token/)
})
test('shows error when token verification fails', async ({ page }) => {
await page.route('**/api/invite/verify', async (route) => {
await route.fulfill({
status: 401,
contentType: 'application/json',
body: JSON.stringify({ statusCode: 401, statusMessage: 'Invalid or expired invitation link' }),
})
})
await gotoAcceptInvite(page)
await expect(page.getByRole('heading', { name: 'Invitation Error' })).toBeVisible()
await expect(page.locator('.error-box')).toContainText(/Invalid or expired/)
})
test('submit disabled until name + agreement filled', async ({ page }) => {
await mockVerifyOk(page, { name: '' })
await gotoAcceptInvite(page)
await expect(page.locator('#accept-name')).toBeVisible()
await expect(page.locator('.form-submit')).toBeDisabled()
await page.locator('#accept-name').fill('New Member')
await expect(page.locator('.form-submit')).toBeDisabled()
await page.getByRole('checkbox', { name: /Community Guidelines/ }).check()
await expect(page.locator('.form-submit')).toBeEnabled()
})
test('cadence toggle updates billing summary total', async ({ page }) => {
await mockVerifyOk(page)
await gotoAcceptInvite(page)
await expect(page.locator('#accept-contribution')).toBeVisible()
await page.locator('#accept-contribution').fill('10')
await page.locator('label[for="accept-cadence-monthly"]').click()
await expect(page.locator('.billing-summary')).toContainText('$10 today')
await page.locator('label[for="accept-cadence-annual"]').click()
await expect(page.locator('.billing-summary')).toContainText('$120 today')
await expect(page.locator('.billing-summary')).toContainText('$10/month')
})
test('preset chip sets contribution amount', async ({ page }) => {
await mockVerifyOk(page)
await gotoAcceptInvite(page)
await expect(page.locator('.contribution-preset-chip').first()).toBeVisible()
const chip = page.locator('.contribution-preset-chip').nth(1)
const chipText = await chip.textContent()
const expected = chipText.replace(/[^0-9]/g, '')
await chip.click()
await expect(page.locator('#accept-contribution')).toHaveValue(expected)
})
test('free tier happy path shows welcome state', async ({ page }) => {
await mockVerifyOk(page, { name: 'Free Tester', email: `free-${Date.now()}@example.com` })
await mockAcceptFree(page)
await gotoAcceptInvite(page)
await expect(page.locator('#accept-name')).toHaveValue('Free Tester')
await page.locator('#circle-community').check({ force: true })
await page.locator('#accept-contribution').fill('0')
await page.getByRole('checkbox', { name: /Community Guidelines/ }).check()
await expect(page.locator('.form-submit')).toBeEnabled()
await expect(page.locator('.form-submit')).toContainText(/Accept Invitation/)
await page.locator('.form-submit').click()
await expect(
page.getByRole('heading', { name: 'Welcome to Ghost Guild!' })
).toBeVisible({ timeout: 15000 })
})
test('paid tier submit button copy switches to Continue to Payment', async ({ page }) => {
await mockVerifyOk(page)
await gotoAcceptInvite(page)
await page.locator('#accept-contribution').fill('10')
await page.getByRole('checkbox', { name: /Community Guidelines/ }).check()
await expect(page.locator('.form-submit')).toContainText(/Continue to Payment/)
})
// Skipped: full paid-tier submission requires intercepting HelcimPay.js modal
// (external script loads an iframe and posts a message back to verifyPayment).
// Feasible but out of scope for this initial coverage pass.
test.skip('paid tier full flow with mocked HelcimPay', async () => {})
})

View file

@ -53,116 +53,3 @@ test.describe('Admin events access control', () => {
expect(page.url()).not.toContain('/admin/events') expect(page.url()).not.toContain('/admin/events')
}) })
}) })
test.describe('Admin events CRUD', () => {
test('create, edit, and delete an event', async ({ adminPage }) => {
const suffix = Date.now().toString().slice(-6)
const title = `e2e-event-${suffix}`
const editedTitle = `e2e-event-${suffix}-edited`
// Re-prime the auth cookie immediately before this multi-step flow.
// The shared test-admin account's tokenVersion is bumped whenever
// auth.spec.js's logout test runs in parallel, which would otherwise
// surface mid-flow as "Session has been revoked" on the first POST.
const loginRes = await adminPage.context().request.get('/api/dev/test-login', { maxRedirects: 0 })
if (loginRes.status() !== 302) {
throw new Error(`Failed to refresh admin session: ${loginRes.status()}`)
}
// --- Create ---
await adminPage.goto('/admin/events/create')
await expect(adminPage.locator('h1')).toContainText('Create Event')
// Ensure Vue has hydrated (initial $fetch for series/tags has resolved)
// before interacting — under cross-file load, hydration can lag and a
// pre-hydration submit will native-POST against an empty form.
await adminPage.waitForLoadState('networkidle')
await adminPage
.getByPlaceholder('Enter a clear, descriptive event title')
.fill(title)
await adminPage
.getByPlaceholder(
'Provide a clear description of what attendees can expect from this event'
)
.fill('e2e test event description')
await adminPage
.getByPlaceholder('e.g., https://zoom.us/j/123... or #channel-name')
.fill('https://example.com/zoom')
const startInput = adminPage.getByPlaceholder(
"e.g., 'tomorrow at 3pm', 'next Friday at 9am'"
)
await startInput.fill('next Tuesday at 3pm')
await startInput.blur()
const endInput = adminPage.getByPlaceholder(
"e.g., 'tomorrow at 5pm', 'next Friday at 11am'"
)
await endInput.fill('next Tuesday at 5pm')
await endInput.blur()
await adminPage.getByRole('button', { name: 'Create Event' }).click()
// The form posts via $fetch and then auto-redirects after a 1.5s setTimeout.
// Under cross-file load that auto-redirect can race against waitForURL.
// Wait for the surfaced success/error state, fail fast on error, then
// navigate explicitly so subsequent assertions are deterministic.
await expect(
adminPage.locator('.success-box').or(adminPage.locator('.error-box'))
).toBeVisible({ timeout: 15000 })
await expect(adminPage.locator('.success-box')).toBeVisible()
await adminPage.goto('/admin/events')
await adminPage.waitForLoadState('networkidle')
// Filter to just our event — orphan rows from prior failed runs can push
// the new row off page 1 of the paginated list.
await adminPage.getByPlaceholder('Search events...').fill(title)
const row = adminPage.locator('tr', { hasText: title })
await expect(row).toBeVisible({ timeout: 10000 })
// --- Edit ---
// Find the event ID from the row's "View" link (href is /events/<slug-or-id>),
// and use the row's Edit button. Pair the click with waitForURL so we don't
// miss the navigation event under load.
await Promise.all([
adminPage.waitForURL(/\/admin\/events\/create\?edit=/, { timeout: 15000 }),
row.getByRole('button', { name: 'Edit' }).click(),
])
await expect(adminPage.locator('h1')).toContainText('Edit Event')
const titleInput = adminPage.getByPlaceholder(
'Enter a clear, descriptive event title'
)
await titleInput.fill(editedTitle)
await adminPage.getByRole('button', { name: 'Update Event' }).click()
await expect(
adminPage.locator('.success-box').or(adminPage.locator('.error-box'))
).toBeVisible({ timeout: 15000 })
await expect(adminPage.locator('.success-box')).toBeVisible()
await adminPage.goto('/admin/events')
await adminPage.waitForLoadState('networkidle')
// Filter to the edited event's unique title for the same pagination reason.
await adminPage.getByPlaceholder('Search events...').fill(editedTitle)
const editedRow = adminPage.locator('tr', { hasText: editedTitle })
await expect(editedRow).toBeVisible({ timeout: 10000 })
// --- Delete (custom modal, not browser dialog) ---
await editedRow.getByRole('button', { name: 'Del' }).click()
await expect(
adminPage.getByRole('heading', { name: 'Delete Event' })
).toBeVisible()
await adminPage
.locator('.modal')
.getByRole('button', { name: 'Delete' })
.click()
await expect(
adminPage.locator('tr', { hasText: editedTitle })
).toHaveCount(0, { timeout: 10000 })
})
})

View file

@ -66,68 +66,4 @@ test.describe("Admin members page", () => {
adminPage.getByPlaceholder("email@example.com"), adminPage.getByPlaceholder("email@example.com"),
).toBeVisible(); ).toBeVisible();
}); });
test("create member, status select reflects STATUS_LABELS, change persists, detail page renders", async ({ adminPage }) => {
const stamp = Date.now();
const memberName = `E2E Member ${stamp}`;
const memberEmail = `e2e-member-${stamp}@example.test`;
await adminPage.goto("/admin/members");
await adminPage.waitForLoadState("networkidle");
await expect(adminPage.locator("h1")).toHaveText("Members");
await adminPage.getByRole("button", { name: "Add Member" }).click();
await adminPage.getByPlaceholder("Full name").fill(memberName);
await adminPage.getByPlaceholder("email@example.com").fill(memberEmail);
await adminPage.getByRole("button", { name: "Create Member" }).click();
// Verify the new member shows up via search
const searchInput = adminPage.getByPlaceholder("Search members...");
await expect(searchInput).toBeVisible({ timeout: 10000 });
await searchInput.fill(memberEmail);
const memberRow = adminPage.locator("tr", { hasText: memberEmail });
await expect(memberRow).toBeVisible({ timeout: 10000 });
await expect(memberRow.getByText(memberName)).toBeVisible();
// Open the edit modal for this member, where the STATUS_LABELS-driven <select> lives
await memberRow.getByRole("button", { name: "Edit" }).click();
const statusSelect = adminPage.locator(".modal select").filter({ hasText: "Active" });
await expect(statusSelect).toBeVisible({ timeout: 10000 });
// STATUS_LABELS keys (values) and the rendered labels
const expectedOptions = [
{ value: "active", label: "Active" },
{ value: "pending_payment", label: "Payment setup incomplete" },
{ value: "suspended", label: "Paused" },
{ value: "cancelled", label: "Closed" },
];
for (const { value, label } of expectedOptions) {
const opt = statusSelect.locator(`option[value="${value}"]`);
await expect(opt).toHaveCount(1);
await expect(opt).toHaveText(label);
}
// Change status to suspended and save
await statusSelect.selectOption("suspended");
await adminPage.getByRole("button", { name: "Save Changes" }).click();
// Modal closes; verify the row badge reflects the new status
await expect(adminPage.locator(".modal")).toHaveCount(0, { timeout: 10000 });
await expect(memberRow.getByText("Paused")).toBeVisible({ timeout: 10000 });
// Reload to confirm persistence
await adminPage.reload();
await adminPage.waitForLoadState("networkidle");
await adminPage.getByPlaceholder("Search members...").fill(memberEmail);
const reloadedRow = adminPage.locator("tr", { hasText: memberEmail });
await expect(reloadedRow.getByText("Paused")).toBeVisible({ timeout: 10000 });
// Click the member name (link to detail page) and verify URL + heading
await reloadedRow.getByRole("link", { name: memberName }).click();
await adminPage.waitForURL(/\/admin\/members\/[a-f0-9]{24}$/, { timeout: 10000 });
await expect(adminPage.locator("h1")).toHaveText(memberName);
await expect(adminPage.locator(".member-email")).toHaveText(memberEmail);
});
}); });

View file

@ -1,111 +0,0 @@
import { test, expect } from './helpers/fixtures.js'
test.describe('Admin pre-registrants page', () => {
test('page loads for admin', async ({ adminPage }) => {
await adminPage.goto('/admin/pre-registrants')
await expect(adminPage.getByRole('heading', { name: 'Pre-Registrants' })).toBeVisible({
timeout: 15000,
})
await expect(
adminPage.locator('table').or(adminPage.getByText('No pre-registrants found matching your criteria')),
).toBeVisible({ timeout: 15000 })
})
test('header action buttons render', async ({ adminPage }) => {
await adminPage.goto('/admin/pre-registrants')
await expect(adminPage.getByRole('heading', { name: 'Pre-Registrants' })).toBeVisible({
timeout: 15000,
})
await expect(adminPage.getByRole('button', { name: /^Mark as Selected/ })).toBeVisible()
await expect(adminPage.getByRole('button', { name: /^Send Invites/ })).toBeVisible()
})
test('search input filters list without crashing', async ({ adminPage }) => {
await adminPage.goto('/admin/pre-registrants')
await adminPage.waitForLoadState('networkidle')
const search = adminPage.getByPlaceholder('Search by name, email, city, role...')
await expect(search).toBeVisible({ timeout: 15000 })
await search.fill(`nonexistent-prereg-${Date.now()}`)
await expect(
adminPage.getByText('No pre-registrants found matching your criteria'),
).toBeVisible({ timeout: 10000 })
})
test('status filter changes selection', async ({ adminPage }) => {
await adminPage.goto('/admin/pre-registrants')
await adminPage.waitForLoadState('networkidle')
const statusFilter = adminPage.getByLabel('Filter by status')
await expect(statusFilter).toBeVisible({ timeout: 15000 })
await statusFilter.selectOption('expired')
await expect(statusFilter).toHaveValue('expired')
await expect(
adminPage.locator('table').or(adminPage.getByText('No pre-registrants found matching your criteria')),
).toBeVisible({ timeout: 10000 })
await statusFilter.selectOption('')
await expect(statusFilter).toHaveValue('')
})
test('Send Invites button is disabled with no selection', async ({ adminPage }) => {
await adminPage.goto('/admin/pre-registrants')
await expect(adminPage.getByRole('heading', { name: 'Pre-Registrants' })).toBeVisible({
timeout: 15000,
})
await expect(adminPage.getByRole('button', { name: 'Send Invites (0)' })).toBeDisabled()
await expect(adminPage.getByRole('button', { name: 'Mark as Selected (0)' })).toBeDisabled()
})
test('send invite action', async ({ adminPage }) => {
await adminPage.goto('/admin/pre-registrants')
await adminPage.waitForLoadState('networkidle')
await expect(adminPage.getByRole('heading', { name: 'Pre-Registrants' })).toBeVisible({
timeout: 15000,
})
// Filter to invitable statuses; pick the first row if available.
const statusFilter = adminPage.getByLabel('Filter by status')
await statusFilter.selectOption('pending')
await adminPage.waitForLoadState('networkidle')
const firstRow = adminPage.locator('tbody tr').first()
if (await firstRow.count() === 0) {
test.skip(true, 'No pending pre-registrants in dev DB to invite')
return
}
await firstRow.locator('.col-name').click()
const sendButton = adminPage.getByRole('button', { name: /^Send Invites \(\d+\)/ })
await expect(sendButton).toBeEnabled()
await sendButton.click()
await expect(adminPage.getByRole('heading', { name: 'Send Invitation Emails' })).toBeVisible()
const submitButton = adminPage.getByRole('button', { name: /^Send \d+ invitation/ })
await submitButton.click()
// ALLOW_DEV_TEST_ENDPOINTS=true short-circuits the Resend call; result still reports sent.
await expect(adminPage.getByText(/^\d+ sent$/)).toBeVisible({ timeout: 15000 })
})
test('non-admin redirect', async ({ browser }) => {
const context = await browser.newContext()
const page = await context.newPage()
await page.goto('/admin/pre-registrants')
await page.waitForURL((url) => !url.pathname.startsWith('/admin'))
expect(page.url()).not.toContain('/admin/pre-registrants')
await context.close()
})
})

View file

@ -1,65 +0,0 @@
import { test, expect } from './helpers/fixtures.js'
test.describe('Admin series management page', () => {
test('series list loads for admin', async ({ adminPage }) => {
await adminPage.goto('/admin/series-management')
await expect(adminPage.getByRole('heading', { name: 'Series', level: 1 })).toBeVisible({
timeout: 15000,
})
await expect(adminPage.getByRole('link', { name: 'Create Series' })).toBeVisible()
})
})
test.describe('Admin series access control', () => {
test('non-admin redirect', async ({ page }) => {
await page.goto('/admin/series-management')
await page.waitForURL((url) => !url.pathname.startsWith('/admin'))
expect(page.url()).not.toContain('/admin/series-management')
})
})
test.describe('Admin series CRUD', () => {
test('create and edit a series', async ({ adminPage }) => {
const suffix = Date.now().toString().slice(-6)
const title = `e2e-series-${suffix}`
const description = 'e2e test series description'
const editedDescription = 'e2e test series description edited'
// --- Create ---
await adminPage.goto('/admin/series/create')
await expect(adminPage.locator('h1')).toContainText('Create New Series')
await adminPage
.getByPlaceholder('e.g., Cooperative Game Development Fundamentals')
.fill(title)
await adminPage
.getByPlaceholder('Describe what the series covers and its goals')
.fill(description)
await adminPage.getByRole('button', { name: 'Create Series' }).click()
await adminPage.waitForURL('**/admin/series-management', { timeout: 15000 })
const card = adminPage.locator('.series-card', { hasText: title })
await expect(card).toBeVisible({ timeout: 10000 })
await expect(card).toContainText(description)
// --- Edit (in-page modal) ---
await card.getByRole('button', { name: 'Edit' }).click()
await expect(adminPage.getByRole('heading', { name: 'Edit Series' })).toBeVisible()
const descInput = adminPage.locator('textarea[placeholder="Brief description of this series"]')
await descInput.fill(editedDescription)
await adminPage.getByRole('button', { name: 'Save Changes' }).click()
const editedCard = adminPage.locator('.series-card', { hasText: title })
await expect(editedCard).toContainText(editedDescription, { timeout: 10000 })
})
// Delete is skipped: the series-management page's "Delete" button only
// unlinks events from the series via PUT /api/admin/events/:id; it does
// not call DELETE /api/admin/series/:id, so the series record remains.
// No UI affordance currently exists to remove an empty series.
test.skip('delete a series', async () => {})
})

View file

@ -1,85 +0,0 @@
import { test, expect } from './helpers/fixtures.js'
const WHITELISTED_KEYS = ['homepage.wiki_feature']
test.describe('Admin site content page', () => {
test('page loads for admin', async ({ adminPage }) => {
await adminPage.goto('/admin/site-content')
await expect(adminPage.getByRole('heading', { name: 'Site Content' })).toBeVisible({
timeout: 15000,
})
})
test('renders one block per whitelisted key', async ({ adminPage }) => {
await adminPage.goto('/admin/site-content')
await adminPage.waitForLoadState('networkidle')
await expect(adminPage.getByRole('heading', { name: 'Site Content' })).toBeVisible({
timeout: 15000,
})
const blocks = adminPage.locator('.content-block')
await expect(blocks).toHaveCount(WHITELISTED_KEYS.length)
for (const key of WHITELISTED_KEYS) {
await expect(adminPage.locator('.block-key', { hasText: key })).toBeVisible()
}
})
test('edit, save, persist, and reflect on public page', async ({ adminPage }) => {
const key = 'homepage.wiki_feature'
await adminPage.goto('/admin/site-content')
await adminPage.waitForLoadState('networkidle')
await expect(adminPage.getByRole('heading', { name: 'Site Content' })).toBeVisible({
timeout: 15000,
})
const original = await adminPage.evaluate(
async (k) => await (await fetch(`/api/site-content/${k}`)).json(),
key,
)
const originalTitle = original.title || ''
const originalBody = original.body || ''
const stamp = Date.now()
const newTitle = `e2e title ${stamp}`
const newBody = `e2e body paragraph ${stamp}`
const block = adminPage.locator('.content-block', {
has: adminPage.locator('.block-key', { hasText: key }),
})
await expect(block).toBeVisible()
const titleInput = block.locator('input[type="text"]')
const bodyTextarea = block.locator('textarea')
await titleInput.fill(newTitle)
await bodyTextarea.fill(newBody)
await block.getByRole('button', { name: 'Save' }).click()
await expect(block.locator('.block-meta')).toContainText('Updated', { timeout: 10000 })
await adminPage.reload()
await adminPage.waitForLoadState('networkidle')
const reloadedBlock = adminPage.locator('.content-block', {
has: adminPage.locator('.block-key', { hasText: key }),
})
await expect(reloadedBlock.locator('input[type="text"]')).toHaveValue(newTitle)
await expect(reloadedBlock.locator('textarea')).toHaveValue(newBody)
await adminPage.goto('/')
await adminPage.waitForLoadState('networkidle')
await expect(adminPage.getByText(newBody)).toBeVisible({ timeout: 15000 })
await adminPage.evaluate(
async ({ k, t, b }) => {
await fetch(`/api/admin/site-content/${k}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: t, body: b }),
})
},
{ k: key, t: originalTitle, b: originalBody },
)
})
})

View file

@ -1,76 +1,45 @@
import { test, expect } from './helpers/fixtures.js' import { test, expect } from './helpers/fixtures.js'
import { loginAsMember } from './helpers/auth.js'
// The default `memberPage` fixture authenticates as test-admin@ghostguild.dev,
// the same account auth.spec.js's logout test revokes mid-suite. Bypass the
// fixture and use a seeded, non-shared member instead so cross-file logout
// can't strand this file mid-flow.
const SEEDED_MEMBER_EMAIL = 'riley.johnson@cooperativedev.org'
const newMemberPage = async (browser) => {
const context = await browser.newContext()
const page = await context.newPage()
await loginAsMember(page, SEEDED_MEMBER_EMAIL)
return { context, page }
}
test.describe('Board page', () => { test.describe('Board page', () => {
test('page loads for authenticated member', async ({ browser }) => { test('page loads for authenticated member', async ({ memberPage }) => {
const { context, page: memberPage } = await newMemberPage(browser) await memberPage.goto('/board')
try { await expect(memberPage.getByRole('heading', { name: 'Board' })).toBeVisible({ timeout: 15000 })
await memberPage.goto('/board') await expect(memberPage.getByRole('button', { name: '+ New Post' }).first()).toBeVisible()
await expect(memberPage.getByRole('heading', { name: 'Bulletin Board' })).toBeVisible({ timeout: 15000 })
await expect(memberPage.getByRole('button', { name: '+ New Post' }).first()).toBeVisible()
} finally {
await context.close()
}
}) })
test('clicking New Post reveals the form', async ({ browser }) => { test('clicking New Post reveals the form', async ({ memberPage }) => {
const { context, page: memberPage } = await newMemberPage(browser) await memberPage.goto('/board')
try { await memberPage.waitForLoadState('networkidle')
await memberPage.goto('/board') await expect(memberPage.getByRole('button', { name: '+ New Post' }).first()).toBeVisible({
await memberPage.waitForLoadState('networkidle') timeout: 15000,
await expect(memberPage.getByRole('button', { name: '+ New Post' }).first()).toBeVisible({ })
timeout: 15000,
})
await memberPage.getByRole('button', { name: '+ New Post' }).first().click() await memberPage.getByRole('button', { name: '+ New Post' }).first().click()
await expect(memberPage.getByRole('heading', { name: 'New post' })).toBeVisible() await expect(memberPage.getByRole('heading', { name: 'New post' })).toBeVisible()
await expect(memberPage.locator('#post-title')).toBeVisible() await expect(memberPage.locator('#post-title')).toBeVisible()
await expect(memberPage.locator('#post-seeking')).toBeVisible() await expect(memberPage.locator('#post-seeking')).toBeVisible()
} finally {
await context.close()
}
}) })
test('tags drawer toggles open and closed', async ({ browser }) => { test('tags drawer toggles open and closed', async ({ memberPage }) => {
const { context, page: memberPage } = await newMemberPage(browser) await memberPage.goto('/board')
try { await expect(memberPage.getByRole('heading', { name: 'Board' })).toBeVisible({ timeout: 15000 })
await memberPage.goto('/board')
await expect(memberPage.getByRole('heading', { name: 'Bulletin Board' })).toBeVisible({ timeout: 15000 })
const drawerToggle = memberPage.getByRole('button', { name: /^Tags\.\.\./ }) const drawerToggle = memberPage.getByRole('button', { name: /^Tags\.\.\./ })
// Drawer toggle only appears if cooperative tags exist — skip quietly if not // Drawer toggle only appears if cooperative tags exist — skip quietly if not
if (!(await drawerToggle.isVisible().catch(() => false))) { if (!(await drawerToggle.isVisible().catch(() => false))) {
test.skip(true, 'No cooperative tags seeded in this environment') test.skip(true, 'No cooperative tags seeded in this environment')
return return
}
await drawerToggle.click()
await expect(memberPage.getByText('Filter:')).toBeVisible()
await drawerToggle.click()
await expect(memberPage.getByText('Filter:')).not.toBeVisible()
} finally {
await context.close()
} }
await drawerToggle.click()
await expect(memberPage.getByText('Filter:')).toBeVisible()
await drawerToggle.click()
await expect(memberPage.getByText('Filter:')).not.toBeVisible()
}) })
test('create, edit, and delete own post', async ({ browser }) => { test('create, edit, and delete own post', async ({ memberPage }) => {
const { context, page: memberPage } = await newMemberPage(browser)
try {
await memberPage.goto('/board') await memberPage.goto('/board')
await memberPage.waitForLoadState('networkidle') await memberPage.waitForLoadState('networkidle')
await expect(memberPage.getByRole('button', { name: '+ New Post' }).first()).toBeVisible({ await expect(memberPage.getByRole('button', { name: '+ New Post' }).first()).toBeVisible({
@ -116,8 +85,5 @@ test.describe('Board page', () => {
await expect(memberPage.getByRole('heading', { name: editedTitle })).not.toBeVisible({ await expect(memberPage.getByRole('heading', { name: editedTitle })).not.toBeVisible({
timeout: 10000, timeout: 10000,
}) })
} finally {
await context.close()
}
}) })
}) })

View file

@ -67,128 +67,3 @@ test.describe('Events list page', () => {
await expect(page.locator('h1')).toBeVisible() await expect(page.locator('h1')).toBeVisible()
}) })
}) })
async function navigateToFirstEventDetail(page) {
await page.goto('/events')
await page.locator('.past-toggle').click()
await page.waitForLoadState('networkidle')
const eventLinks = page.locator('.event-row a')
const count = await eventLinks.count()
if (count === 0) return null
const href = await eventLinks.first().getAttribute('href')
return href
}
test.describe('Event detail — ticket gating', () => {
test('series-pass-required shows pass-required notice instead of buy button', async ({ page }) => {
const href = await navigateToFirstEventDetail(page)
test.skip(!href, 'No events in dev DB to navigate against')
await page.route('**/api/events/*/tickets/available**', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
available: false,
reason: 'series_pass_required',
requiresSeriesPass: true,
series: { id: 'series-stub', slug: 'series-stub', title: 'Stub Series' }
})
})
})
await page.route('**/api/events/*/check-series-access**', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ requiresSeriesPass: false })
})
})
await page.locator(`.event-row a[href="${href}"]`).first().click()
await page.waitForURL(`**${href}`)
const ticketPanel = page.locator('.event-ticket-purchase')
await expect(ticketPanel.locator('.ticket-status', { hasText: 'Series Pass Required' })).toBeVisible()
await expect(ticketPanel.locator('button', { hasText: /Pay |Register for this event|Complete Registration/ })).toHaveCount(0)
await expect(ticketPanel.locator('a[href="/series/series-stub"] button')).toBeVisible()
})
test('memberSavings line is hidden for anonymous viewers', async ({ page }) => {
const href = await navigateToFirstEventDetail(page)
test.skip(!href, 'No events in dev DB to navigate against')
await page.route('**/api/events/*/tickets/available**', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
available: true,
alreadyRegistered: false,
isFree: false,
isMember: false,
name: 'General Admission',
formattedPrice: '$25.00',
remaining: 10,
memberSavings: 0,
publicTicket: null
})
})
})
await page.locator(`.event-row a[href="${href}"]`).first().click()
await page.waitForURL(`**${href}`)
const ticketCard = page.locator('.ticket-card')
await expect(ticketCard).toBeVisible()
await expect(page.locator('.ticket-savings')).toHaveCount(0)
await expect(page.locator('text=/save .* as a member/i')).toHaveCount(0)
})
test('memberSavings line is shown when API reports savings', async ({ page }) => {
const href = await navigateToFirstEventDetail(page)
test.skip(!href, 'No events in dev DB to navigate against')
await page.route('**/api/events/*/tickets/available**', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
available: true,
alreadyRegistered: false,
isFree: false,
isMember: true,
name: 'Member Ticket',
formattedPrice: '$10.00',
remaining: 10,
memberSavings: 15,
publicTicket: { formattedPrice: '$25.00' }
})
})
})
await page.locator(`.event-row a[href="${href}"]`).first().click()
await page.waitForURL(`**${href}`)
const savings = page.locator('.ticket-savings')
await expect(savings).toBeVisible()
await expect(savings).toContainText(/save/i)
})
test.skip('hidden event returns 404', async () => {
// Skipped: hidden-event gating happens during SSR useFetch in [slug].vue,
// which page.route cannot intercept. Verifying this gate requires either
// seeding a hidden event in the dev DB or a server-side mock layer.
})
test.skip('past-deadline event shows registration-closed copy', async () => {
// Skipped: when the available endpoint returns reason
// "Registration deadline has passed", the current UI surfaces it as the
// generic "Event Sold Out" panel — there is no distinct "Registration
// closed" string to assert against without changing the component.
})
test.skip('member with paid registration cannot self-cancel', async () => {
// Skipped: requires seeding an authed member with a paid registration in
// the DB, which is out of scope for API-level mocking.
})
})

View file

@ -1,32 +1,36 @@
/** /**
* Login helpers using dev endpoints. * Login helpers using dev endpoints.
* * These set real httpOnly JWT cookies so all middleware works naturally.
* Implementation note: hits the dev endpoints via the APIRequestContext */
* (no page navigation). The Set-Cookie response writes auth-token to the
* BrowserContext's cookie jar, so any subsequent page.goto() is authed. /**
* Avoids the Nuxt-dev networkidle race that made page.goto-based login flaky. * Login as admin via the dev test-login endpoint.
* Creates a test admin user if none exists and sets the auth cookie.
* Waits for networkidle so the client-side auth check (admin middleware +
* auth-init plugin) completes before the test navigates anywhere.
*/ */
export async function loginAsAdmin(page) { export async function loginAsAdmin(page) {
const res = await page.context().request.get('/api/dev/test-login', { maxRedirects: 0 }) await page.goto('/api/dev/test-login', { waitUntil: 'domcontentloaded' })
if (res.status() !== 302) {
throw new Error(`/api/dev/test-login returned ${res.status()}; expected 302`) // The endpoint sets the cookie and redirects to /admin.
} // waitForURL fires as soon as the URL changes — not when JS finishes.
const cookies = await page.context().cookies() // waitForLoadState('networkidle') ensures the auth-init plugin and admin
if (!cookies.find((c) => c.name === 'auth-token')) { // middleware have both completed their checkMemberStatus() calls before
throw new Error('/api/dev/test-login did not set auth-token cookie') // the test proceeds.
try {
await page.waitForURL(/\/admin/, { timeout: 15000 })
await page.waitForLoadState('networkidle')
} catch {
// Cookie should be set even if redirect failed — navigate manually
await page.goto('/admin', { waitUntil: 'networkidle' })
await page.waitForURL(/\/admin/)
} }
} }
/**
* Login as a specific member by email via the dev member-login endpoint.
*/
export async function loginAsMember(page, email) { export async function loginAsMember(page, email) {
const res = await page.context().request.get( await page.goto(`/api/dev/member-login?email=${encodeURIComponent(email)}`, { waitUntil: 'domcontentloaded' })
`/api/dev/member-login?email=${encodeURIComponent(email)}`, await page.waitForURL(/\/member\//)
{ maxRedirects: 0 }
)
if (res.status() !== 302) {
throw new Error(`/api/dev/member-login returned ${res.status()}; expected 302`)
}
const cookies = await page.context().cookies()
if (!cookies.find((c) => c.name === 'auth-token')) {
throw new Error('/api/dev/member-login did not set auth-token cookie')
}
} }

View file

@ -104,104 +104,6 @@ test.describe('Join page — member signup flow', () => {
).toBeVisible({ timeout: 15000 }) ).toBeVisible({ timeout: 15000 })
}) })
test('cadence toggle updates billing summary to annual ×12', async ({ page }) => {
await page.goto('/join')
await page.waitForLoadState('networkidle')
await page.locator('#join-contribution').fill('10')
await page.locator('label[for="cadence-annual"]').click()
const summary = page.locator('.billing-summary')
await expect(summary).toBeVisible()
await expect(summary).toContainText('$120 today')
await expect(summary).toContainText('$10/month × 12')
await expect(summary).toContainText('$120 every year')
await page.locator('label[for="cadence-monthly"]').click()
await expect(summary).toContainText('$10 today')
await expect(summary).toContainText('$10 every month')
})
test('contribution guidance label changes with amount tier', async ({ page }) => {
await page.goto('/join')
await page.waitForLoadState('networkidle')
const guidance = page.locator('.contribution-guidance')
await page.locator('#join-contribution').fill('5')
await expect(guidance).toHaveText(/I can contribute/)
await page.locator('#join-contribution').fill('30')
await expect(guidance).toHaveText(/I can support others too/)
})
test('paid tier flow reaches success state with HelcimPay stubbed', async ({ page }) => {
const uniqueEmail = `test-e2e-paid-${Date.now()}@example.com`
// Stub HelcimPay window globals before the page loads so the composable's
// script-load path is bypassed and we resolve verifyPayment synchronously.
await page.addInitScript(() => {
window.appendHelcimPayIframe = (checkoutToken) => {
const eventName = 'helcim-pay-js-' + checkoutToken
setTimeout(() => {
window.postMessage({
eventName,
eventStatus: 'SUCCESS',
eventMessage: JSON.stringify({
data: {
data: {
transactionId: 'stub-txn-1',
cardToken: 'stub-card-token-1',
cardNumber: '4111111111111234',
cardType: 'visa'
}
}
})
}, '*')
}, 50)
}
window.removeHelcimPayIframe = () => {}
})
await page.goto('/join')
await page.waitForLoadState('networkidle')
await mockHelcimAPIs(page)
await page.route('**/api/helcim/initialize-payment', async (route) => {
return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
success: true,
checkoutToken: 'stub-checkout-token',
secretToken: 'stub-secret-token'
})
})
})
await page.route('**/api/helcim/verify-payment', async (route) => {
return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ success: true })
})
})
await page.locator('#join-name').fill('Paid E2E User')
await page.locator('#join-email').fill(uniqueEmail)
await page.locator('#circle-community').check({ force: true })
await page.locator('#join-contribution').fill('15')
await page.getByRole('checkbox', { name: /Community Guidelines/ }).check()
await expect(page.locator('.form-submit')).toBeEnabled()
await page.locator('.form-submit').click()
await expect(
page.getByRole('heading', { name: 'Welcome to Ghost Guild!' })
).toBeVisible({ timeout: 15000 })
})
test('duplicate email shows error', async ({ page }) => { test('duplicate email shows error', async ({ page }) => {
const duplicateEmail = `test-e2e-dup-${Date.now()}@example.com` const duplicateEmail = `test-e2e-dup-${Date.now()}@example.com`

180
e2e/visual/pages.spec.js Normal file
View file

@ -0,0 +1,180 @@
import { test, expect } from '@playwright/test'
import { loginAsAdmin } from '../helpers/auth.js'
import path from 'path'
import fs from 'fs'
const viewports = {
desktop: { width: 1280, height: 720 },
mobile: { width: 375, height: 667 },
}
const publicPages = [
{ name: 'home', path: '/' },
{ name: 'join', path: '/join' },
{ name: 'events', path: '/events' },
{ name: 'coming-soon', path: '/coming-soon' },
// about and members have no auth middleware — accessible publicly
{ name: 'about', path: '/about' },
{ name: 'members', path: '/members' },
]
const authenticatedPages = [
{ name: 'member-dashboard', path: '/member/dashboard' },
{ name: 'member-profile', path: '/member/profile' },
{ name: 'admin-members', path: '/admin/members' },
{ name: 'admin-events-create', path: '/admin/events/create' },
// New authenticated pages
{ name: 'member-account', path: '/member/account' },
{ name: 'connections', path: '/connections' },
{ name: 'admin-dashboard', path: '/admin' },
]
// Pages that need mobile coverage captured while authenticated.
// These cover column-collapse breakpoints critical for the page-shell refactor.
// Snapshots use the -mobile-auth suffix to distinguish from the public mobile loop
// (which also captures about-mobile unauthenticated, so names must not collide).
const authenticatedMobilePages = [
{ name: 'about', path: '/about' },
{ name: 'member-dashboard', path: '/member/dashboard' },
{ name: 'member-profile', path: '/member/profile' },
{ name: 'member-account', path: '/member/account' },
{ name: 'connections', path: '/connections' },
]
// Path where the saved admin auth state (cookies) will be stored within a run.
const authStatePath = path.resolve('e2e/.auth/admin.json')
// Wait for fonts and images to load before taking screenshots
async function waitForStable(page) {
await page.waitForLoadState('networkidle')
// Wait for web fonts to load
await page.evaluate(() => document.fonts.ready)
}
// Common mask selectors for dynamic content
function commonMasks(page) {
return [
// Dates and times throughout the app
page.locator('.event-date'),
page.locator('.event-count'),
page.locator('time'),
page.locator('.member-since'),
// Activity log timestamps
page.locator('.tl-time'),
// Admin dashboard stat values (member counts, revenue, etc.)
page.locator('.stat-val'),
// Recent member join dates in admin dashboard
page.locator('.item-date'),
// Member avatars (ghost images may not load deterministically)
page.locator('.mc-avatar'),
page.locator('.cc-avatar'),
page.locator('.profile-avatar'),
// Member count text in members page filter bar
page.locator('.filter-count'),
// Connections page: filter bar and suggestions vary based on tag/topic
// state and async fetch ordering. Mask them to keep the structural
// (PageShell + page-level) regression coverage stable.
page.locator('.filter-bar'),
page.locator('.skills-bar'),
page.locator('.connections-section'),
page.locator('.loading-state'),
]
}
// All visual tests run serially in a single top-level describe block.
//
// Auth is handled with a beforeAll that saves the cookie to disk once. All
// authenticated sub-describes load from that saved state, avoiding repeated
// /api/dev/test-login calls that exhaust the dev server's MongoDB connections.
test.describe('visual regression', () => {
test.describe.configure({ mode: 'serial' })
// Log in once before all tests and save the auth cookie.
// serial mode guarantees this runs before any test in this describe tree.
test.beforeAll(async ({ browser }) => {
fs.mkdirSync(path.dirname(authStatePath), { recursive: true })
const page = await browser.newPage()
await loginAsAdmin(page)
await page.context().storageState({ path: authStatePath })
await page.close()
})
// ── Public pages (desktop + mobile) ──────────────────────────────────────
test.describe('public pages', () => {
for (const { name, path } of publicPages) {
for (const [viewportName, viewport] of Object.entries(viewports)) {
test(`${name}${viewportName}`, async ({ page }) => {
await page.setViewportSize(viewport)
await page.goto(path)
await waitForStable(page)
await expect(page).toHaveScreenshot(`${name}-${viewportName}.png`, {
maxDiffPixelRatio: 0.01,
mask: commonMasks(page),
})
})
}
}
})
// ── Authenticated pages (desktop) ─────────────────────────────────────────
// Loads saved auth cookie — no repeated /api/dev/test-login calls.
test.describe('authenticated pages', () => {
test.use({ storageState: authStatePath })
for (const { name, path } of authenticatedPages) {
test(`${name} — desktop`, async ({ page }) => {
await page.setViewportSize(viewports.desktop)
await page.goto(path)
await waitForStable(page)
await expect(page).toHaveScreenshot(`${name}-desktop.png`, {
maxDiffPixelRatio: 0.01,
mask: commonMasks(page),
})
})
}
// members-detail: navigate to the test admin's own profile page.
// The test admin is created by /api/dev/test-login (email: test-admin@ghostguild.dev,
// status: active). We fetch their _id from /api/auth/member using the saved cookie.
// Even if showInDirectory is false, the page renders a stable error or profile shell.
test('members-detail — desktop', async ({ page }) => {
await page.setViewportSize(viewports.desktop)
const response = await page.request.get('/api/auth/member')
// /api/auth/member returns the member object directly (not nested under a 'member' key)
const authData = response.ok() ? await response.json() : null
const memberId = authData?._id || authData?.id
if (!memberId) {
// Skip gracefully if we can't retrieve the member ID
test.skip(true, 'Could not retrieve test admin member ID from /api/auth/member')
return
}
await page.goto(`/members/${memberId}`)
await waitForStable(page)
await expect(page).toHaveScreenshot('members-detail-desktop.png', {
maxDiffPixelRatio: 0.01,
mask: commonMasks(page),
})
})
})
// ── Authenticated pages (mobile — column-collapse coverage) ───────────────
// Loads saved auth cookie — no repeated /api/dev/test-login calls.
test.describe('authenticated pages (mobile)', () => {
test.use({ storageState: authStatePath })
for (const { name, path } of authenticatedMobilePages) {
test(`${name} — mobile`, async ({ page }) => {
await page.setViewportSize(viewports.mobile)
await page.goto(path)
await waitForStable(page)
await expect(page).toHaveScreenshot(`${name}-mobile-auth.png`, {
maxDiffPixelRatio: 0.01,
mask: commonMasks(page),
})
})
}
})
})

View file

@ -1,222 +1,103 @@
// Spec: docs/specs/wave-based-slack-onboarding.md // Spec: docs/specs/wave-based-slack-onboarding.md
// Test plan: docs/specs/wave-based-slack-onboarding-tests.md §6 + §7 // Test plan: docs/specs/wave-based-slack-onboarding-tests.md §6 + §7
//
// SCAFFOLD: every test is `.skip`ed and contains a TODO. As the UI lands,
// unskip and fill in selectors / fixtures.
//
// These cover the rendered behavior that unit tests can't: dashboard line
// visibility under different member statuses, and the admin-list "Mark as
// Slack invited" button + status display.
import { test, expect } from './helpers/fixtures.js' import { test, expect } from './helpers/fixtures.js'
import { loginAsMember } from './helpers/auth.js'
const SLACK_NOTE_RE = /Slack workspace access is part of your membership/i
test.describe('Member dashboard — Slack-coming note (§7)', () => { test.describe('Member dashboard — Slack-coming note (§7)', () => {
test('shows note for active member without Slack (7.1)', async ({ browser }) => { test.skip('shows note for active member without Slack (7.1)', async () => {
const context = await browser.newContext() // TODO: seed a member { status: 'active', slackInvited: false }, sign in,
const page = await context.newPage() // navigate to /member/dashboard, assert the one-liner is visible:
await loginAsMember(page, 'riley.johnson@cooperativedev.org') // await expect(page.getByText(/within 2.3 weeks/i)).toBeVisible()
await page.goto('/member/dashboard')
await expect(page.getByRole('heading', { name: /Welcome.*Riley/i })).toBeVisible({ timeout: 15000 })
await expect(page.getByText(SLACK_NOTE_RE)).toBeVisible()
await context.close()
}) })
test.skip('hides note once slackInvited:true (7.2)', async () => { test.skip('hides note once slackInvited:true (7.2)', async () => {
// BUG: /api/auth/member does not return slackInvited, so memberData.slackInvited // TODO: same as 7.1 but with slackInvited:true; assert text not present.
// is always undefined on the client. The dashboard condition
// (status==="active" && !slackInvited) currently shows the note for ALL
// active members regardless of slackInvited. Fix the API to expose the
// field before unskipping.
}) })
test('hides note for pending_payment member (7.3)', async ({ browser }) => { test.skip('hides note for pending_payment member (7.3)', async () => {
const context = await browser.newContext() // TODO: pending_payment + slackInvited:false; assert text not present.
const page = await context.newPage()
await loginAsMember(page, 'pending-payment-test@example.test')
await page.goto('/member/dashboard')
await expect(page.getByRole('heading', { name: /Welcome.*Pending Payment Tester/i })).toBeVisible({ timeout: 15000 })
await expect(page.getByText(SLACK_NOTE_RE)).toHaveCount(0)
await context.close()
}) })
test.skip('hides note for suspended/cancelled/guest (7.4)', async () => { test.skip('hides note for suspended/cancelled/guest (7.4)', async () => {
// No suspended/cancelled/guest members exist in the dev DB and there is // TODO: parameterize across statuses { suspended, cancelled, guest }.
// no dev endpoint to seed members with arbitrary status. Implementing
// this would require a new server-side helper (out of scope).
}) })
test.skip('copy contains no wave/cohort/batch language (7.5)', async () => { test.skip('copy contains no wave/cohort/batch language (7.5)', async ({ adminPage }) => {
// The shipped UI uses the phrase "monthly onboarding waves" — this test's await adminPage.goto('/member/dashboard')
// \bwave\b assertion contradicts the current copy. Resolve the spec/UI const html = await adminPage.content()
// divergence before unskipping. expect(html).not.toMatch(/\bwave\b/i)
expect(html).not.toMatch(/\bcohort\b/i)
expect(html).not.toMatch(/\bbatch\b/i)
}) })
test('renders as plain text — no banner / modal / callout styling (7.6)', async ({ browser }) => { test.skip('renders as plain text — no banner / modal / callout styling (7.6)', async () => {
const context = await browser.newContext() // TODO: assert the note's container is not a UAlert / modal / heavy callout
const page = await context.newPage() // (e.g. no .alert, no role="dialog" wrapper).
await loginAsMember(page, 'riley.johnson@cooperativedev.org')
await page.goto('/member/dashboard')
await expect(page.getByRole('heading', { name: /Welcome.*Riley/i })).toBeVisible({ timeout: 15000 })
const note = page.getByText(SLACK_NOTE_RE)
await expect(note).toBeVisible()
const tag = await note.evaluate((el) => el.tagName.toLowerCase())
expect(tag).toBe('p')
const inDialog = await note.evaluate((el) => !!el.closest('[role="dialog"]'))
expect(inDialog).toBe(false)
const inAlert = await note.evaluate((el) => !!el.closest('[role="alert"], .alert'))
expect(inAlert).toBe(false)
await context.close()
}) })
test('SSR renders without auth — note absent (7.7)', async ({ browser }) => { test.skip('SSR renders without auth — note absent (7.7)', async ({ browser }) => {
const context = await browser.newContext() const context = await browser.newContext()
const page = await context.newPage() const page = await context.newPage()
const response = await page.goto('/member/dashboard') const response = await page.goto('/member/dashboard')
const ssrHtml = await response.text() const ssrHtml = await response.text()
expect(ssrHtml).not.toMatch(SLACK_NOTE_RE) expect(ssrHtml).not.toMatch(/within 2.3 weeks/i)
await context.close() await context.close()
}) })
test.skip('copy matches approved wording (7.8)', async () => { test.skip('copy matches approved wording (7.8)', async () => {
// Awaiting resolution of the Open Question on the final approved string. // TODO: replace with the final approved string once the Open Question is resolved.
}) })
}) })
test.describe('Admin members — Slack-invited control (§6)', () => { test.describe('Admin members — Slack-invited control (§6)', () => {
test('shows "Mark as Slack invited" for slackInvited:false (6.1)', async ({ adminPage }) => { test.skip('shows "Mark as Slack invited" for slackInvited:false (6.1)', async ({ adminPage }) => {
await adminPage.goto('/admin/members') await adminPage.goto('/admin/members')
await expect(adminPage.getByRole('heading', { name: 'Members' })).toBeVisible() // TODO: locate a row for a member with slackInvited:false and assert the
await expect( // button is visible.
adminPage.getByRole('button', { name: /Mark as Slack invited/i }).first() // await expect(adminPage.getByRole('button', { name: /Mark as Slack invited/i })).toBeVisible()
).toBeVisible()
}) })
test.skip('replaces button with "Invited <date>" once flipped (6.2)', async () => { test.skip('replaces button with "Invited <date>" once flipped (6.2)', async () => {
// BUG: in admin/members/index.vue, markSlackInvited does // TODO: click the button on a row; assert button is gone, date string visible.
// Object.assign(member, res.member) on a plain object inside the
// useFetch array — Vue does not pick up the per-item mutation, so the
// row UI does not refresh until the page reloads. The same control on
// the detail page (which reassigns member.value) does work — see 6.6.
}) })
test('click triggers single PATCH and updates row in place (6.4)', async ({ adminPage }) => { test.skip('click triggers single PATCH and updates row in place (6.4)', async ({ adminPage }) => {
// Re-prime the auth cookie. The shared test-admin account's tokenVersion // TODO: spy on network for /api/admin/members/*/slack-status; click button;
// is bumped whenever auth.spec.js's logout test runs in parallel, which // assert single PATCH, success, no full-page reload.
// would otherwise surface mid-flow as a silent 401 on the create POST.
const loginRes = await adminPage.context().request.get('/api/dev/test-login', { maxRedirects: 0 })
if (loginRes.status() !== 302) {
throw new Error(`Failed to refresh admin session: ${loginRes.status()}`)
}
// Create a dedicated test member so the row we operate on is uniquely
// identifiable by email and can't be displaced by parallel test mutations.
// We use the admin UI flow (vs API) because the POST endpoint is
// CSRF-protected and the modal is the documented happy path.
const stamp = Date.now()
const memberEmail = `e2e-slack-6-4-${stamp}@example.test`
const memberName = `E2E Slack 6.4 ${stamp}`
await adminPage.goto('/admin/members')
await expect(adminPage.getByRole('heading', { name: 'Members' })).toBeVisible()
await adminPage.waitForLoadState('networkidle')
await adminPage.getByRole('button', { name: 'Add Member' }).click()
await adminPage.getByPlaceholder('Full name').fill(memberName)
await adminPage.getByPlaceholder('email@example.com').fill(memberEmail)
await adminPage.getByRole('button', { name: 'Create Member' }).click()
// Modal closes after successful create
await expect(adminPage.getByPlaceholder('Full name')).toHaveCount(0, { timeout: 10000 })
const patchRequests = []
await adminPage.route('**/api/admin/members/*/slack-status', async (route) => {
const req = route.request()
patchRequests.push({ method: req.method(), url: req.url() })
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
success: true,
member: {
slackInvited: true,
slackInvitedAt: new Date().toISOString(),
},
}),
})
})
await adminPage.goto('/admin/members')
await expect(adminPage.getByRole('heading', { name: 'Members' })).toBeVisible()
// Wait for hydration so v-model bindings on the search input are wired up
// and the click on the row's button reaches the Vue handler.
await adminPage.waitForLoadState('networkidle')
// Filter the list down to our specific member so the row anchor is unambiguous.
const searchInput = adminPage.getByPlaceholder('Search members...')
await expect(searchInput).toBeVisible({ timeout: 10000 })
await searchInput.fill(memberEmail)
const targetRow = adminPage.locator('tbody tr', { hasText: memberEmail })
await expect(targetRow).toBeVisible({ timeout: 10000 })
// Wait until the table has filtered down to only our row — confirms the
// search v-model has been processed.
await expect(adminPage.locator('tbody tr')).toHaveCount(1, { timeout: 10000 })
await targetRow.getByRole('button', { name: /Mark as Slack invited/i }).click()
await expect.poll(() => patchRequests.length, { timeout: 5000 }).toBe(1)
expect(patchRequests[0].method).toBe('PATCH')
expect(patchRequests[0].url).toMatch(/\/api\/admin\/members\/[^/]+\/slack-status$/)
await adminPage.waitForTimeout(500)
expect(patchRequests).toHaveLength(1)
}) })
test('status labels read "Not yet invited" / "Invited" — not "Pending" (6.5)', async ({ adminPage }) => { test.skip('status labels read "Not yet invited" / "Invited" — not "Pending" (6.5)', async ({ adminPage }) => {
await adminPage.goto('/admin/members')
// TODO:
// await expect(adminPage.getByText(/Not yet invited/i).first()).toBeVisible()
// const html = await adminPage.content()
// expect(html).not.toMatch(/Slack:\s*Pending/i)
})
test.skip('member detail page mirrors list controls (6.6)', async () => {
// TODO: navigate to /admin/members/<id>; assert button + date display.
})
test.skip('no UI references slackInviteStatus (6.7)', async ({ adminPage }) => {
// Static assertion of rendered HTML — no leftover badge labels keyed off the dropped field.
await adminPage.goto('/admin/members') await adminPage.goto('/admin/members')
await expect(adminPage.getByRole('heading', { name: 'Members' })).toBeVisible()
await expect(adminPage.getByText('Not yet invited').first()).toBeVisible()
const html = await adminPage.content() const html = await adminPage.content()
expect(html).not.toMatch(/Slack:\s*Pending/i) expect(html).not.toMatch(/slackInviteStatus/)
}) })
test('member detail page mirrors list controls (6.6)', async ({ adminPage }) => { test.skip('UI rolls back on PATCH error — no false "Invited" badge (6.8)', async () => {
await adminPage.goto('/admin/members') // TODO: mock the endpoint to return 500; assert the row stays in
await expect(adminPage.getByRole('heading', { name: 'Members' })).toBeVisible() // "Not yet invited" state.
const row = adminPage.locator('tr', {
has: adminPage.getByRole('button', { name: /Mark as Slack invited/i }),
}).first()
const href = await row.locator('a.member-name-link').getAttribute('href')
expect(href).toMatch(/\/admin\/members\/[a-f0-9]+/)
await adminPage.goto(href)
await expect(adminPage.getByText('Slack invite', { exact: true })).toBeVisible()
await expect(adminPage.getByText('Not yet invited').first()).toBeVisible()
await expect(adminPage.getByRole('button', { name: /Mark as Slack invited/i })).toBeVisible()
})
test.skip('no UI references slackInviteStatus (6.7)', async () => {
// The deprecated slackInviteStatus field still lives on Member documents
// and is serialized into the /api/admin/members payload (visible in the
// SSR Nuxt state). The admin UI itself does not reference the field, but
// a content() check against the rendered HTML matches the JSON payload.
// Cleaning up the DB field is out of scope for this test pass.
})
test('UI rolls back on PATCH error — no false "Invited" badge (6.8)', async ({ adminPage }) => {
await adminPage.route('**/api/admin/members/*/slack-status', async (route) => {
await route.fulfill({
status: 500,
contentType: 'application/json',
body: JSON.stringify({ statusMessage: 'Server error' }),
})
})
await adminPage.goto('/admin/members')
await expect(adminPage.getByRole('heading', { name: 'Members' })).toBeVisible()
const row = adminPage.locator('tr', {
has: adminPage.getByRole('button', { name: /Mark as Slack invited/i }),
}).first()
await row.getByRole('button', { name: /Mark as Slack invited/i }).click()
await expect(row.getByText('Not yet invited')).toBeVisible()
await expect(row.getByText(/^Invited\s+\d/)).toHaveCount(0)
await expect(row.getByRole('button', { name: /Mark as Slack invited/i })).toBeVisible()
}) })
test.skip('proposed: sortable on slackInvitedAt + filter "no Slack yet" (6.9)', async () => { test.skip('proposed: sortable on slackInvitedAt + filter "no Slack yet" (6.9)', async () => {
// Dependent on Open Question — wire up if implemented. // TODO: dependent on Open Question — wire up if implemented.
}) })
}) })

View file

@ -6,10 +6,11 @@ const BASE_URL = `http://localhost:${PORT}`;
export default defineConfig({ export default defineConfig({
testDir: "./e2e", testDir: "./e2e",
outputDir: "e2e/test-results", outputDir: "e2e/test-results",
fullyParallel: false, snapshotDir: "e2e/__screenshots__",
fullyParallel: true,
forbidOnly: !!process.env.CI, forbidOnly: !!process.env.CI,
retries: process.env.CI ? 1 : 1, retries: process.env.CI ? 1 : 0,
workers: process.env.CI ? 1 : 4, workers: process.env.CI ? 1 : undefined,
reporter: "html", reporter: "html",
timeout: 60000, timeout: 60000,
use: { use: {
@ -26,7 +27,7 @@ export default defineConfig({
webServer: { webServer: {
command: `PORT=${PORT} npm run build && PORT=${PORT} NODE_ENV=development npm run preview`, command: `PORT=${PORT} npm run build && PORT=${PORT} NODE_ENV=development npm run preview`,
url: BASE_URL, url: BASE_URL,
reuseExistingServer: true, reuseExistingServer: !process.env.CI,
env: { env: {
NUXT_PUBLIC_COMING_SOON: "false", NUXT_PUBLIC_COMING_SOON: "false",
NODE_ENV: "development", NODE_ENV: "development",

View file

@ -274,18 +274,6 @@ const sampleMembers = [
createdAt: new Date('2025-06-01'), createdAt: new Date('2025-06-01'),
lastLogin: new Date('2026-04-04'), lastLogin: new Date('2026-04-04'),
}, },
{
email: 'pending-payment-test@example.test',
name: 'Pending Payment Tester',
circle: 'community',
contributionAmount: 5,
status: 'pending_payment',
slackInvited: false,
craftTags: [],
board: {},
createdAt: new Date('2026-04-25'),
lastLogin: new Date('2026-04-29'),
},
] ]
const TEST_ADMIN_BOARD = { const TEST_ADMIN_BOARD = {

View file

@ -1,72 +0,0 @@
import mongoose from 'mongoose'
import PreRegistration from '../server/models/preRegistration.js'
import { connectDB } from '../server/utils/mongoose.js'
import dotenv from 'dotenv'
dotenv.config()
// 30 mock pre-registrants with realistic game dev / co-op roles and cities
const samplePreRegistrants = [
{ email: 'lina.okoro@gmail.com', name: 'Lina Okoro', city: 'Lagos, Nigeria', role: 'Game designer', status: 'pending', newsletterOptIn: true, createdAt: new Date('2025-11-02') },
{ email: 'marco.bianchi@proton.me', name: 'Marco Bianchi', city: 'Milan, Italy', role: 'Narrative designer', status: 'pending', newsletterOptIn: true, createdAt: new Date('2025-11-05') },
{ email: 'priya.nair@outlook.com', name: 'Priya Nair', city: 'Bangalore, India', role: 'Unity developer', status: 'pending', newsletterOptIn: false, createdAt: new Date('2025-11-08') },
{ email: 'elke.hoffmann@posteo.de', name: 'Elke Hoffmann', city: 'Berlin, Germany', role: 'Producer', status: 'pending', newsletterOptIn: true, createdAt: new Date('2025-11-12') },
{ email: 'tomoko.sato@icloud.com', name: 'Tomoko Sato', city: 'Tokyo, Japan', role: 'Pixel artist', status: 'pending', newsletterOptIn: true, createdAt: new Date('2025-11-15') },
{ email: 'jamie.callahan@fastmail.com', name: 'Jamie Callahan', city: 'Vancouver, BC', role: 'Co-op founder', status: 'pending', newsletterOptIn: true, createdAt: new Date('2025-11-18') },
{ email: 'yusuf.demir@gmail.com', name: 'Yusuf Demir', city: 'Istanbul, Turkey', role: 'Sound designer', status: 'pending', newsletterOptIn: false, createdAt: new Date('2025-11-20') },
{ email: 'saoirse.murphy@proton.me', name: 'Saoirse Murphy', city: 'Dublin, Ireland', role: 'QA lead', status: 'pending', newsletterOptIn: true, createdAt: new Date('2025-11-22') },
{ email: 'ren.watanabe@gmail.com', name: 'Ren Watanabe', city: 'Osaka, Japan', role: 'Godot developer', status: 'pending', newsletterOptIn: true, createdAt: new Date('2025-11-25') },
{ email: 'astrid.lindgren@tuta.io', name: 'Astrid Lindgren', city: 'Stockholm, Sweden', role: '3D artist', status: 'pending', newsletterOptIn: false, createdAt: new Date('2025-12-01') },
{ email: 'carlos.reyes@gmail.com', name: 'Carlos Reyes', city: 'Mexico City, Mexico', role: 'Programmer', status: 'pending', newsletterOptIn: true, createdAt: new Date('2025-12-04') },
{ email: 'noor.hassan@outlook.com', name: 'Noor Hassan', city: 'Amman, Jordan', role: 'UX researcher', status: 'pending', newsletterOptIn: true, createdAt: new Date('2025-12-07') },
{ email: 'freya.johansson@pm.me', name: 'Freya Johansson', city: 'Copenhagen, Denmark', role: 'Studio co-founder', status: 'pending', newsletterOptIn: true, createdAt: new Date('2025-12-10') },
{ email: 'kwame.asante@gmail.com', name: 'Kwame Asante', city: 'Accra, Ghana', role: 'Game developer', status: 'pending', newsletterOptIn: false, createdAt: new Date('2025-12-13') },
{ email: 'mila.petrov@proton.me', name: 'Mila Petrov', city: 'Belgrade, Serbia', role: 'Animator', status: 'pending', newsletterOptIn: true, createdAt: new Date('2025-12-16') },
{ email: 'odin.haugen@fastmail.com', name: 'Odin Haugen', city: 'Oslo, Norway', role: 'Cooperative advisor', status: 'pending', newsletterOptIn: true, createdAt: new Date('2025-12-19') },
{ email: 'chen.wei@icloud.com', name: 'Chen Wei', city: 'Taipei, Taiwan', role: 'Indie developer', status: 'pending', newsletterOptIn: true, createdAt: new Date('2025-12-22') },
{ email: 'lucia.romano@gmail.com', name: 'Lucia Romano', city: 'Buenos Aires, Argentina', role: 'Level designer', status: 'pending', newsletterOptIn: false, createdAt: new Date('2025-12-28') },
{ email: 'imani.williams@proton.me', name: 'Imani Williams', city: 'Toronto, ON', role: 'Community manager', status: 'pending', newsletterOptIn: true, createdAt: new Date('2026-01-03') },
{ email: 'felix.dubois@pm.me', name: 'Felix Dubois', city: 'Montreal, QC', role: 'Technical artist', status: 'pending', newsletterOptIn: true, createdAt: new Date('2026-01-06') },
{ email: 'anika.schuster@posteo.de', name: 'Anika Schuster', city: 'Vienna, Austria', role: 'Writer', status: 'pending', newsletterOptIn: true, createdAt: new Date('2026-01-10') },
{ email: 'rohan.kapoor@gmail.com', name: 'Rohan Kapoor', city: 'Mumbai, India', role: 'Studio founder', status: 'pending', newsletterOptIn: false, createdAt: new Date('2026-01-14') },
{ email: 'emeka.obi@outlook.com', name: 'Emeka Obi', city: 'Nairobi, Kenya', role: 'Mobile game dev', status: 'pending', newsletterOptIn: true, createdAt: new Date('2026-01-18') },
{ email: 'sofie.bakker@tuta.io', name: 'Sofie Bakker', city: 'Amsterdam, Netherlands', role: 'Cooperative organizer', status: 'pending', newsletterOptIn: true, createdAt: new Date('2026-01-22') },
{ email: 'mateo.silva@gmail.com', name: 'Mateo Silva', city: 'Bogota, Colombia', role: 'Concept artist', status: 'pending', newsletterOptIn: true, createdAt: new Date('2026-01-26') },
{ email: 'hana.kim@proton.me', name: 'Hana Kim', city: 'Seoul, South Korea', role: 'Unreal developer', status: 'pending', newsletterOptIn: false, createdAt: new Date('2026-02-01') },
{ email: 'zara.thompson@fastmail.com', name: 'Zara Thompson', city: 'London, UK', role: 'Producer', status: 'pending', newsletterOptIn: true, createdAt: new Date('2026-02-05') },
{ email: 'leo.moreau@pm.me', name: 'Leo Moreau', city: 'Lyon, France', role: 'Gameplay programmer', status: 'pending', newsletterOptIn: true, createdAt: new Date('2026-02-10') },
{ email: 'cleo.nguyen@gmail.com', name: 'Cleo Nguyen', city: 'Ho Chi Minh City, Vietnam', role: 'Environment artist', status: 'pending', newsletterOptIn: true, createdAt: new Date('2026-02-15') },
{ email: 'kai.eriksson@icloud.com', name: 'Kai Eriksson', city: 'Helsinki, Finland', role: 'Cooperative consultant', status: 'pending', newsletterOptIn: false, createdAt: new Date('2026-02-20') },
]
async function seedPreRegistrants() {
try {
await connectDB()
await PreRegistration.deleteMany({})
console.log('Cleared existing pre-registrants')
await PreRegistration.insertMany(samplePreRegistrants)
console.log(`Added ${samplePreRegistrants.length} sample pre-registrants`)
const count = await PreRegistration.countDocuments()
console.log(`Total pre-registrants in database: ${count}`)
const statusBreakdown = await PreRegistration.aggregate([
{ $group: { _id: '$status', count: { $sum: 1 } } },
{ $sort: { _id: 1 } }
])
console.log('\nBreakdown by status:')
statusBreakdown.forEach(s => {
console.log(` ${s._id}: ${s.count}`)
})
process.exit(0)
} catch (error) {
console.error('Error seeding pre-registrants:', error)
process.exit(1)
}
}
seedPreRegistrants()

View file

@ -24,29 +24,22 @@ export default defineEventHandler(async (event) => {
let channelName = body.name let channelName = body.name
if (!slackChannelId) { if (!slackChannelId) {
if (process.env.ALLOW_DEV_TEST_ENDPOINTS === 'true') { const slack = getSlackAdminService()
// Match the Slack channel ID format (^[A-Z0-9]+$) so the value if (!slack) {
// round-trips through boardChannelUpdateSchema on subsequent edits. throw createError({
slackChannelId = `CDEV${Date.now().toString(36).toUpperCase()}` statusCode: 500,
console.log('[slack] DEV MODE — skipping createChannel', { name: body.name, slackChannelId }) statusMessage: 'Slack integration not configured',
} else { })
const slack = getSlackAdminService() }
if (!slack) { try {
throw createError({ const created = await slack.createChannel(body.name)
statusCode: 500, slackChannelId = created.id
statusMessage: 'Slack integration not configured', channelName = created.name
}) } catch (err) {
} throw createError({
try { statusCode: 502,
const created = await slack.createChannel(body.name) statusMessage: `Failed to create Slack channel: ${err.data?.error || err.message}`,
slackChannelId = created.id })
channelName = created.name
} catch (err) {
throw createError({
statusCode: 502,
statusMessage: `Failed to create Slack channel: ${err.data?.error || err.message}`,
})
}
} }
} }

View file

@ -7,9 +7,7 @@ export default defineEventHandler(async (event) => {
await requireAdmin(event) await requireAdmin(event)
await connectDB() await connectDB()
const projection = Object.keys(Member.schema.paths).join(' ')
const members = await Member.find() const members = await Member.find()
.select(projection)
.sort({ createdAt: -1 }) .sort({ createdAt: -1 })
.lean() .lean()

View file

@ -8,8 +8,7 @@ export default defineEventHandler(async (event) => {
await connectDB() await connectDB()
const projection = Object.keys(Member.schema.paths).join(' ') const member = await Member.findById(memberId).lean()
const member = await Member.findById(memberId).select(projection).lean()
if (!member) { if (!member) {
throw createError({ statusCode: 404, statusMessage: 'Member not found' }) throw createError({ statusCode: 404, statusMessage: 'Member not found' })
} }

View file

@ -63,23 +63,17 @@ export default defineEventHandler(async (event) => {
.replace(/\n/g, '<br>') .replace(/\n/g, '<br>')
.replace(/\{acceptLink\}/g, acceptButton) .replace(/\{acceptLink\}/g, acceptButton)
const subject = "You're invited to Ghost Guild! 👻" const { error: emailError } = await resend.emails.send({
from: 'Ghost Guild <welcome@babyghosts.org>',
to: [preReg.email],
subject: "You're invited to Ghost Guild! 👻",
text: emailText,
html: emailHtml,
})
if (process.env.ALLOW_DEV_TEST_ENDPOINTS === 'true') { if (emailError) {
console.log('[resend] DEV MODE — skipping invite send', { to: preReg.email, subject }) results.push({ preRegistrantId: preReg._id, email: preReg.email, success: false, error: emailError.message })
} else { continue
const { error: emailError } = await resend.emails.send({
from: 'Ghost Guild <welcome@babyghosts.org>',
to: [preReg.email],
subject,
text: emailText,
html: emailHtml,
})
if (emailError) {
results.push({ preRegistrantId: preReg._id, email: preReg.email, success: false, error: emailError.message })
continue
}
} }
await PreRegistration.findByIdAndUpdate(preReg._id, { await PreRegistration.findByIdAndUpdate(preReg._id, {

View file

@ -17,8 +17,6 @@ export default defineEventHandler(async (event) => {
helcimCustomerCode: member.helcimCustomerCode, helcimCustomerCode: member.helcimCustomerCode,
nextBillingDate: member.nextBillingDate, nextBillingDate: member.nextBillingDate,
membershipLevel: `${member.circle}-${member.contributionAmount}`, membershipLevel: `${member.circle}-${member.contributionAmount}`,
slackInvited: member.slackInvited,
slackInvitedAt: member.slackInvitedAt,
// Profile fields // Profile fields
pronouns: member.pronouns, pronouns: member.pronouns,
timeZone: member.timeZone, timeZone: member.timeZone,

View file

@ -2,9 +2,8 @@ import { getRequestHeader, getRequestIP } from 'h3'
import Member from '../../models/member.js' import Member from '../../models/member.js'
import { connectDB } from '../../utils/mongoose.js' import { connectDB } from '../../utils/mongoose.js'
import { createHelcimCustomer } from '../../utils/helcim.js' import { createHelcimCustomer } from '../../utils/helcim.js'
import PreRegistration from '../../models/preRegistration.js'
import { sendMagicLink } from '../../utils/magicLink.js' import { sendMagicLink } from '../../utils/magicLink.js'
import { setSignupBridgeCookie } from '../../utils/auth.js' import { setPaymentBridgeCookie } from '../../utils/auth.js'
import { rateLimit } from '../../utils/rateLimit.js' import { rateLimit } from '../../utils/rateLimit.js'
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
@ -83,32 +82,6 @@ export default defineEventHandler(async (event) => {
}) })
} }
// If this email matches a pending pre-registrant, mark the PreRegistration
// as accepted and link it to the new Member. Silent — keeps /join and
// /admin/pre-registrants from showing the same person twice.
try {
const preReg = await PreRegistration.findOne({ email: normalizedEmail })
if (
preReg &&
!preReg.memberId &&
['pending', 'selected', 'invited'].includes(preReg.status)
) {
await PreRegistration.findByIdAndUpdate(
preReg._id,
{
$set: {
status: 'accepted',
acceptedAt: new Date(),
memberId: member._id,
},
},
{ runValidators: false }
)
}
} catch (linkError) {
console.error('Failed to link PreRegistration to new member:', linkError)
}
await sendMagicLink(normalizedEmail, { await sendMagicLink(normalizedEmail, {
subject: 'Verify your Ghost Guild signup', subject: 'Verify your Ghost Guild signup',
intro: 'Verify your email to finish your Ghost Guild signup:', intro: 'Verify your email to finish your Ghost Guild signup:',
@ -116,10 +89,10 @@ export default defineEventHandler(async (event) => {
}) })
// Signup completes (paid checkout or free activation) before the magic // Signup completes (paid checkout or free activation) before the magic
// link is clicked, so issue a short-lived signup-bridge cookie that lets // link is clicked, so issue a short-lived, payment-only bridge cookie
// /api/helcim/initialize-payment and /api/helcim/subscription identify // that lets /api/helcim/initialize-payment and /api/helcim/subscription
// the member without a verified auth session. // identify the member without a verified auth session.
setSignupBridgeCookie(event, member) setPaymentBridgeCookie(event, member)
return { return {
success: true, success: true,

View file

@ -2,7 +2,7 @@ import Member from '../../models/member.js'
import { loadPublicEvent } from '../../utils/loadEvent.js' import { loadPublicEvent } from '../../utils/loadEvent.js'
import { loadPublicSeries } from '../../utils/loadSeries.js' import { loadPublicSeries } from '../../utils/loadSeries.js'
import { calculateTicketPrice, calculateSeriesTicketPrice, hasMemberAccess } from '../../utils/tickets.js' import { calculateTicketPrice, calculateSeriesTicketPrice, hasMemberAccess } from '../../utils/tickets.js'
import { requireAuth, getOptionalMember, getSignupBridgeMember } from '../../utils/auth.js' import { requireAuth, getOptionalMember, getPaymentBridgeMember } from '../../utils/auth.js'
import { initializeHelcimPaySession } from '../../utils/helcim.js' import { initializeHelcimPaySession } from '../../utils/helcim.js'
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
@ -17,7 +17,7 @@ export default defineEventHandler(async (event) => {
if (!isTicket) { if (!isTicket) {
if (isMembershipSignup) { if (isMembershipSignup) {
const bridgeMember = await getSignupBridgeMember(event) const bridgeMember = await getPaymentBridgeMember(event)
if (!bridgeMember) { if (!bridgeMember) {
await requireAuth(event) await requireAuth(event)
} }

View file

@ -3,7 +3,7 @@ import { getHelcimPlanId, requiresPayment } from '../../config/contributions.js'
import Member from '../../models/member.js' import Member from '../../models/member.js'
import { connectDB } from '../../utils/mongoose.js' import { connectDB } from '../../utils/mongoose.js'
import { getSlackService } from '../../utils/slack.ts' import { getSlackService } from '../../utils/slack.ts'
import { requireAuth, getSignupBridgeMember } from '../../utils/auth.js' import { requireAuth, getPaymentBridgeMember } from '../../utils/auth.js'
import { createHelcimSubscription, generateIdempotencyKey, listHelcimCustomerTransactions } from '../../utils/helcim.js' import { createHelcimSubscription, generateIdempotencyKey, listHelcimCustomerTransactions } from '../../utils/helcim.js'
import { sendWelcomeEmail } from '../../utils/resend.js' import { sendWelcomeEmail } from '../../utils/resend.js'
import { upsertPaymentFromHelcim } from '../../utils/payments.js' import { upsertPaymentFromHelcim } from '../../utils/payments.js'
@ -11,8 +11,8 @@ import { upsertPaymentFromHelcim } from '../../utils/payments.js'
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
try { try {
// Membership signup completes subscription before email verify; allow the // Membership signup completes subscription before email verify; allow the
// signup-bridge cookie set by /api/helcim/customer to satisfy auth here. // payment-bridge cookie set by /api/helcim/customer to satisfy auth here.
const bridgeMember = await getSignupBridgeMember(event) const bridgeMember = await getPaymentBridgeMember(event)
if (!bridgeMember) { if (!bridgeMember) {
await requireAuth(event) await requireAuth(event)
} }

View file

@ -5,7 +5,6 @@ import { connectDB } from '../../utils/mongoose.js'
import { setAuthCookie } from '../../utils/auth.js' import { setAuthCookie } from '../../utils/auth.js'
import { assignMemberNumber } from '../../utils/memberNumber.js' import { assignMemberNumber } from '../../utils/memberNumber.js'
import { createHelcimCustomer } from '../../utils/helcim.js' import { createHelcimCustomer } from '../../utils/helcim.js'
import { sendWelcomeEmail } from '../../utils/resend.js'
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
const body = await validateBody(event, inviteAcceptSchema) const body = await validateBody(event, inviteAcceptSchema)
@ -89,15 +88,6 @@ export default defineEventHandler(async (event) => {
// For free tier, redirect to welcome // For free tier, redirect to welcome
if (body.contributionAmount === 0) { if (body.contributionAmount === 0) {
await autoFlagPreExistingSlackAccess(member) await autoFlagPreExistingSlackAccess(member)
try {
await sendWelcomeEmail(member)
logActivity(member._id, 'email_sent', {
emailType: 'welcome',
subject: 'Welcome to Ghost Guild'
})
} catch (emailError) {
console.error('Failed to send welcome email:', emailError)
}
return { return {
success: true, success: true,
requiresPayment: false, requiresPayment: false,

View file

@ -23,27 +23,26 @@ export function setAuthCookie(event, member) {
} }
/** /**
* Issue a 30-minute signup-bridge cookie scoped to membership-signup flow. * Issue a 30-minute payment-bridge cookie scoped to membership-signup checkout.
* *
* The signup flow (POST /api/helcim/customer) defers the full session cookie * The signup flow (POST /api/helcim/customer) defers the full session cookie
* to email-verify (magic link). The bridge cookie lets the in-progress signup * to email-verify (magic link). For paid tiers the user still needs to complete
* complete its activation step (free or paid) before that magic link is * Helcim checkout in the same browser tab this short-lived, payment-only
* clicked: /api/helcim/subscription accepts it for $0 activation, and * token lets `/api/helcim/initialize-payment` accept the call without a full
* /api/helcim/initialize-payment accepts it for paid Helcim checkout. * session. The cookie is NOT honored by requireAuth and grants nothing else.
* The cookie is NOT honored by requireAuth and grants nothing else.
*/ */
export function setSignupBridgeCookie(event, member) { export function setPaymentBridgeCookie(event, member) {
const token = jwt.sign( const token = jwt.sign(
{ {
memberId: member._id.toString(), memberId: member._id.toString(),
email: member.email, email: member.email,
scope: 'signup_bridge' scope: 'payment_bridge'
}, },
useRuntimeConfig(event).jwtSecret, useRuntimeConfig(event).jwtSecret,
{ expiresIn: '30m' } { expiresIn: '30m' }
) )
setCookie(event, 'signup-bridge', token, { setCookie(event, 'payment-bridge', token, {
httpOnly: true, httpOnly: true,
secure: process.env.NODE_ENV === 'production', secure: process.env.NODE_ENV === 'production',
sameSite: 'lax', sameSite: 'lax',
@ -53,12 +52,12 @@ export function setSignupBridgeCookie(event, member) {
} }
/** /**
* Verify a signup-bridge cookie and return the associated Member, or null. * Verify a payment-bridge cookie and return the associated Member, or null.
* Used by /api/helcim/subscription and /api/helcim/initialize-payment to * Used by /api/helcim/initialize-payment to allow the membership-signup
* let the in-progress signup complete activation before email verification. * checkout to proceed before email verification.
*/ */
export async function getSignupBridgeMember(event) { export async function getPaymentBridgeMember(event) {
const token = getCookie(event, 'signup-bridge') const token = getCookie(event, 'payment-bridge')
if (!token) return null if (!token) return null
let decoded let decoded
@ -68,7 +67,7 @@ export async function getSignupBridgeMember(event) {
return null return null
} }
if (decoded.scope !== 'signup_bridge') return null if (decoded.scope !== 'payment_bridge') return null
await connectDB() await connectDB()
const member = await Member.findById(decoded.memberId) const member = await Member.findById(decoded.memberId)

View file

@ -7,10 +7,7 @@ export const connectDB = async () => {
return; return;
} }
const MONGODB_URI = const MONGODB_URI = useRuntimeConfig().mongodbUri;
typeof useRuntimeConfig === 'function'
? useRuntimeConfig().mongodbUri
: process.env.NUXT_MONGODB_URI || process.env.MONGODB_URI;
try { try {
await mongoose.connect(MONGODB_URI, { await mongoose.connect(MONGODB_URI, {

View file

@ -282,7 +282,7 @@ Welcome to Ghost Guild! You're now part of the ${member.circle} circle.
Sign in to your dashboard to get started: Sign in to your dashboard to get started:
${baseUrl}/member/dashboard ${baseUrl}/member/dashboard
If you have questions, just reply to this email.`, If you have questions, reach out to jennie + eileen on Slack or reply to this email.`,
}); });
if (error) { if (error) {

View file

@ -45,7 +45,7 @@ vi.mock('../../../server/models/preRegistration.js', () => ({
vi.mock('../../../server/utils/mongoose.js', () => ({ connectDB: vi.fn() })) vi.mock('../../../server/utils/mongoose.js', () => ({ connectDB: vi.fn() }))
vi.mock('../../../server/utils/auth.js', () => ({ vi.mock('../../../server/utils/auth.js', () => ({
requireAuth: vi.fn(), requireAuth: vi.fn(),
getSignupBridgeMember: vi.fn().mockResolvedValue(null), getPaymentBridgeMember: vi.fn().mockResolvedValue(null),
setAuthCookie: vi.fn() setAuthCookie: vi.fn()
})) }))
vi.mock('../../../server/utils/slack.ts', () => ({ vi.mock('../../../server/utils/slack.ts', () => ({

View file

@ -20,9 +20,6 @@ vi.mock('../../../server/models/member.js', () => ({
findOneAndUpdate: vi.fn() findOneAndUpdate: vi.fn()
} }
})) }))
vi.mock('../../../server/models/preRegistration.js', () => ({
default: { findOne: vi.fn().mockResolvedValue(null), findByIdAndUpdate: vi.fn() }
}))
vi.mock('../../../server/utils/mongoose.js', () => ({ connectDB: vi.fn() })) vi.mock('../../../server/utils/mongoose.js', () => ({ connectDB: vi.fn() }))
vi.mock('../../../server/utils/helcim.js', () => ({ vi.mock('../../../server/utils/helcim.js', () => ({
createHelcimCustomer: vi.fn(), createHelcimCustomer: vi.fn(),
@ -60,9 +57,9 @@ const SUBSCRIPTION_BODY = {
function extractBridgeCookie(event) { function extractBridgeCookie(event) {
const setCookie = event.node.res.getHeader('set-cookie') const setCookie = event.node.res.getHeader('set-cookie')
const cookies = Array.isArray(setCookie) ? setCookie : [setCookie].filter(Boolean) const cookies = Array.isArray(setCookie) ? setCookie : [setCookie].filter(Boolean)
const match = cookies.find(c => typeof c === 'string' && c.startsWith('signup-bridge=')) const match = cookies.find(c => typeof c === 'string' && c.startsWith('payment-bridge='))
if (!match) return null if (!match) return null
return match.match(/signup-bridge=([^;]+)/)[1] return match.match(/payment-bridge=([^;]+)/)[1]
} }
describe('signup → subscription bridge-cookie hand-off', () => { describe('signup → subscription bridge-cookie hand-off', () => {
@ -104,7 +101,7 @@ describe('signup → subscription bridge-cookie hand-off', () => {
expect(result1.member.status).toBe('pending_payment') expect(result1.member.status).toBe('pending_payment')
const bridgeToken = extractBridgeCookie(customerEvent) const bridgeToken = extractBridgeCookie(customerEvent)
expect(bridgeToken, 'signup-bridge cookie missing on $0 signup').toBeTruthy() expect(bridgeToken, 'payment-bridge cookie missing on $0 signup').toBeTruthy()
Member.findOneAndUpdate.mockResolvedValue({ _id: MEMBER_ID, status: 'pending_payment' }) Member.findOneAndUpdate.mockResolvedValue({ _id: MEMBER_ID, status: 'pending_payment' })
Member.findById.mockResolvedValue({ Member.findById.mockResolvedValue({
@ -120,7 +117,7 @@ describe('signup → subscription bridge-cookie hand-off', () => {
method: 'POST', method: 'POST',
path: '/api/helcim/subscription', path: '/api/helcim/subscription',
headers: { origin: ALLOWED_ORIGIN }, headers: { origin: ALLOWED_ORIGIN },
cookies: { 'signup-bridge': bridgeToken }, cookies: { 'payment-bridge': bridgeToken },
body: SUBSCRIPTION_BODY body: SUBSCRIPTION_BODY
}) })

View file

@ -3,7 +3,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'
import Member from '../../../server/models/member.js' import Member from '../../../server/models/member.js'
import { createHelcimCustomer } from '../../../server/utils/helcim.js' import { createHelcimCustomer } from '../../../server/utils/helcim.js'
import { sendMagicLink } from '../../../server/utils/magicLink.js' import { sendMagicLink } from '../../../server/utils/magicLink.js'
import { setAuthCookie, setSignupBridgeCookie } from '../../../server/utils/auth.js' import { setAuthCookie, setPaymentBridgeCookie } from '../../../server/utils/auth.js'
import customerHandler from '../../../server/api/helcim/customer.post.js' import customerHandler from '../../../server/api/helcim/customer.post.js'
import { resetRateLimit } from '../../../server/utils/rateLimit.js' import { resetRateLimit } from '../../../server/utils/rateLimit.js'
import { createMockEvent } from '../helpers/createMockEvent.js' import { createMockEvent } from '../helpers/createMockEvent.js'
@ -12,9 +12,6 @@ import { createMockEvent } from '../helpers/createMockEvent.js'
vi.mock('../../../server/models/member.js', () => ({ vi.mock('../../../server/models/member.js', () => ({
default: { findOne: vi.fn(), create: vi.fn(), findByIdAndUpdate: vi.fn() } default: { findOne: vi.fn(), create: vi.fn(), findByIdAndUpdate: vi.fn() }
})) }))
vi.mock('../../../server/models/preRegistration.js', () => ({
default: { findOne: vi.fn().mockResolvedValue(null), findByIdAndUpdate: vi.fn() }
}))
vi.mock('../../../server/utils/mongoose.js', () => ({ connectDB: vi.fn() })) vi.mock('../../../server/utils/mongoose.js', () => ({ connectDB: vi.fn() }))
vi.mock('../../../server/utils/helcim.js', () => ({ vi.mock('../../../server/utils/helcim.js', () => ({
createHelcimCustomer: vi.fn() createHelcimCustomer: vi.fn()
@ -24,7 +21,7 @@ vi.mock('../../../server/utils/magicLink.js', () => ({
})) }))
vi.mock('../../../server/utils/auth.js', () => ({ vi.mock('../../../server/utils/auth.js', () => ({
setAuthCookie: vi.fn(), setAuthCookie: vi.fn(),
setSignupBridgeCookie: vi.fn() setPaymentBridgeCookie: vi.fn()
})) }))
// helcimCustomerSchema is auto-imported in the handler — stub it to a passthrough // helcimCustomerSchema is auto-imported in the handler — stub it to a passthrough
@ -303,7 +300,7 @@ describe('POST /api/helcim/customer', () => {
'guest@example.com', 'guest@example.com',
expect.objectContaining({ subject: 'Verify your Ghost Guild signup' }) expect.objectContaining({ subject: 'Verify your Ghost Guild signup' })
) )
expect(setSignupBridgeCookie).toHaveBeenCalled() expect(setPaymentBridgeCookie).toHaveBeenCalled()
expect(setAuthCookie).not.toHaveBeenCalled() expect(setAuthCookie).not.toHaveBeenCalled()
// Response shape mirrors new-signup case AND surfaces the preserved _id. // Response shape mirrors new-signup case AND surfaces the preserved _id.
@ -365,7 +362,7 @@ describe('POST /api/helcim/customer', () => {
) )
}) })
it('sets a signup-bridge cookie on paid-tier signup so checkout can proceed', async () => { it('sets a payment-bridge cookie on paid-tier signup so checkout can proceed', async () => {
const event = build({ const event = build({
body: { body: {
name: 'Paid User', name: 'Paid User',
@ -376,7 +373,7 @@ describe('POST /api/helcim/customer', () => {
} }
}) })
await customerHandler(event) await customerHandler(event)
expect(setSignupBridgeCookie).toHaveBeenCalled() expect(setPaymentBridgeCookie).toHaveBeenCalled()
expect(sendMagicLink).toHaveBeenCalledWith( expect(sendMagicLink).toHaveBeenCalledWith(
'paid@example.com', 'paid@example.com',
expect.objectContaining({ subject: 'Verify your Ghost Guild signup' }) expect.objectContaining({ subject: 'Verify your Ghost Guild signup' })

View file

@ -15,7 +15,7 @@ vi.mock('../../../server/models/member.js', () => ({
vi.mock('../../../server/utils/mongoose.js', () => ({ connectDB: vi.fn() })) vi.mock('../../../server/utils/mongoose.js', () => ({ connectDB: vi.fn() }))
vi.mock('../../../server/utils/auth.js', () => ({ vi.mock('../../../server/utils/auth.js', () => ({
requireAuth: vi.fn(), requireAuth: vi.fn(),
getSignupBridgeMember: vi.fn().mockResolvedValue(null) getPaymentBridgeMember: vi.fn().mockResolvedValue(null)
})) }))
vi.mock('../../../server/utils/slack.ts', () => ({ vi.mock('../../../server/utils/slack.ts', () => ({
getSlackService: vi.fn().mockReturnValue(null) getSlackService: vi.fn().mockReturnValue(null)