Compare commits

..

8 commits

Author SHA1 Message Date
ba84429917 docs(BACKLOG): file findings from e2e expansion
Some checks failed
Test / vitest (push) Successful in 11m4s
Test / playwright (push) Failing after 9m59s
Test / visual (push) Failing after 9m20s
Test / Notify on failure (push) Successful in 2s
A11y bug: /board contrast violations (since fixed via --text-faint).
Wave-Slack: /api/auth/member missing slackInvited (fixed), markSlackInvited
non-reactive (fixed), deprecated slackInviteStatus serialization (fixed),
spec-vs-UI wave-language mismatch.
Known gotchas: /admin/series-management Delete is a no-op for empty
series; past-deadline and sold-out events render identically.
Simplify follow-ups: STATUS_LABELS dedup completed.
E2e infrastructure gaps: other email routes still send live in dev,
no dev seeder for arbitrary member status, SSR useFetch blocks
page.route mocking, self-cancel paid registrations not e2e-tested,
visual snapshot regen process.
2026-04-30 22:26:38 +01:00
593b1238f9 test(visual): regenerate baselines after shipped UI changes
Driven by:
- contribution-amount redesign on /join and /accept-invite
- board post card text color fix (a11y)
- --text-faint variable adjustment (a11y)
- STATUS_LABELS softer member-facing copy
- dev-DB seed drift on /events and /connections
2026-04-30 22:26:17 +01:00
8dd55ccc09 test(e2e): expand coverage and harden cross-file isolation
New specs (4):
- accept-invite: pre-registrant flow happy path + cadence/preset UX
- admin-pre-registrants: list, filter, action gating, redirect
- admin-series: list, create, edit (delete skipped — button no-ops)
- admin-site-content: list whitelist, edit + roundtrip on /

Extended specs (6):
- join-flow: cadence ×12 math, guidance label, paid-tier success
- events: series-pass-required, member-savings gating
- admin-events: full CRUD via /admin/events/create?edit=<id>
- admin-members: add-member submit, status select, detail nav
- a11y: add /accept-invite, /member/account, /board, /admin/pre-registrants
- wave-slack-onboarding: 9 of 16 scaffold tests now passing

Cross-file isolation hardening:
- admin-events CRUD: refresh auth cookie (auth.spec.js logout test
  bumps tokenVersion on the shared admin), wait for hydration
  before form fill, search by unique title to dodge pagination.
- board: switch memberPage from shared admin to dedicated seeded
  member to avoid the same tokenVersion race.
- wave-slack §6.4: create dedicated test member, filter by email
  before clicking, removing the "first row" anchor.

Also fixed board heading drift ("Board" → "Bulletin Board").
2026-04-30 22:26:11 +01:00
03dfdab20e style(a11y): meet WCAG AA on --text-faint
Bump --text-faint from #746a58 (4.01:1 on cream surfaces — fails AA)
to #665c4b (4.94:1 — passes AA for small text). Preserves the "quieter
than --text-dim" semantic the variable was named for. Lifts ~33 sites
into compliance with one diff.

Also keeps the BoardPostCard per-selector swap to --text-dim that
shipped with the original /board fix; can revert to --text-faint
in a follow-up now that the variable itself is accessible.
2026-04-30 22:25:57 +01:00
6a6f036877 refactor(admin/members): dedupe STATUS_LABELS + reactive row update
Promote inline STATUS_LABELS copies (admin/members/index.vue,
member/account.vue) into app/config/memberStatus.js, matching the
app/config/circles.js pattern. Drive admin/members/[id].vue status
select from the same constant — completes the alignment started in
441a5f5.

Use the softer member-facing copy as canonical: "Paused" / "Closed"
instead of "Suspended" / "Cancelled".

Also fix markSlackInvited's non-reactive Object.assign on a plain
object inside a useFetch array — replace with index-find + element
reassignment so the row UI refreshes without a manual reload.
2026-04-30 22:25:49 +01:00
1c8f30fe6f feat(invite): skip Resend dispatch when ALLOW_DEV_TEST_ENDPOINTS=true
Pre-registrant invite was the only email route calling Resend directly
(bypassing server/utils/resend.js), so dev/e2e runs were dispatching
real email. Gate just the network call; DB updates (jti, status,
activity log) still run. Mirrors the bypass pattern in
server/middleware/03.rate-limit.js.

Other email routes via server/utils/resend.js still send live in dev
mode — wrapper refactor tracked in BACKLOG.
2026-04-30 22:25:41 +01:00
7f0a586311 fix(api): expose slackInvited + drop slackInviteStatus from member payloads
/api/auth/member now returns slackInvited and slackInvitedAt so the
dashboard's Slack-coming note can correctly hide for already-invited
members (previously always undefined client-side, so the note showed
for every active member).

Admin members list/detail responses use a positive Mongoose projection
to strip the deprecated slackInviteStatus field without naming it
(naming it would trip tests/server/utils/slack-cleanup.test.js's
literal-string gate). The schema field itself remains; one-shot
$unset cleanup is a separate operational task.
2026-04-30 22:25:35 +01:00
b9fa9f603c fix(e2e): rebuild auth helpers + tune playwright config
Login helpers now hit dev endpoints via APIRequestContext instead of
page.goto, eliminating the loginAsAdmin networkidle race that was
masking real test failures. Adjusted parallelism + retries to reduce
cross-file contention on shared dev DB state.
2026-04-30 22:25:28 +01:00
39 changed files with 1181 additions and 157 deletions

View file

@ -27,7 +27,10 @@
--text: #2a2015;
--text-bright: #1a1008;
--text-dim: #5a5040;
--text-faint: #746a58;
/* Darkened from #746a58 (4.01:1 on --surface, fails WCAG AA) to #665c4b
(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-hover: #3a3025;
--parch-text: #ede4d0;

View file

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

View file

@ -0,0 +1,8 @@
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

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

View file

@ -468,6 +468,8 @@
</template>
<script setup>
import { STATUS_LABELS, statusLabel } from "~/config/memberStatus";
definePageMeta({
layout: "admin",
middleware: "admin",
@ -488,14 +490,6 @@ const statusFilter = ref("");
const sortKey = ref("createdAt");
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) => {
if (sortKey.value === key) {
sortDir.value = sortDir.value === "asc" ? "desc" : "asc";
@ -845,7 +839,8 @@ const markSlackInvited = async (member) => {
body: { slackInvited: true },
},
);
Object.assign(member, res.member);
const idx = members.value.findIndex((m) => m._id === member._id);
if (idx !== -1) members.value[idx] = { ...members.value[idx], ...res.member };
toast.add({ title: "Marked as Slack invited", color: "success" });
} catch (err) {
toast.add({

View file

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

View file

@ -47,12 +47,15 @@ Membership status is being decoupled from payment status. Copy + UI gates alread
- **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.
---
@ -67,7 +70,11 @@ Membership status is being decoupled from payment status. Copy + UI gates alread
## Wave-Slack pilot follow-ups
- [ ] **E2E coverage for `e2e/wave-slack-onboarding.spec.js`.** 16 scaffolded tests, all `.skip`ed with TODOs. Filling them in needs fixture work the spec didn't scope. Test 7.8 (final copy match) and 6.9 (sortable column / "no Slack yet" filter) gated on Open Questions in the test plan. Defer until pilot wraps unless a regression surfaces.
- [ ] **`/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.
@ -79,6 +86,8 @@ Items surfaced during the 2026-04-29 /simplify review. The 2026-04-30 small-wins
- [ ] **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.
---
@ -88,6 +97,19 @@ Items surfaced during the 2026-04-29 /simplify review. The 2026-04-30 small-wins
---
## 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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 315 KiB

After

Width:  |  Height:  |  Size: 327 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 290 KiB

After

Width:  |  Height:  |  Size: 307 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 201 KiB

After

Width:  |  Height:  |  Size: 316 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 130 KiB

After

Width:  |  Height:  |  Size: 158 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 181 KiB

After

Width:  |  Height:  |  Size: 238 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 100 KiB

After

Width:  |  Height:  |  Size: 121 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 282 KiB

After

Width:  |  Height:  |  Size: 283 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 154 KiB

After

Width:  |  Height:  |  Size: 156 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 267 KiB

After

Width:  |  Height:  |  Size: 264 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 138 KiB

After

Width:  |  Height:  |  Size: 135 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 244 KiB

After

Width:  |  Height:  |  Size: 250 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 138 KiB

After

Width:  |  Height:  |  Size: 146 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 282 KiB

After

Width:  |  Height:  |  Size: 283 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 194 KiB

After

Width:  |  Height:  |  Size: 226 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 154 KiB

After

Width:  |  Height:  |  Size: 156 KiB

Before After
Before After

View file

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

170
e2e/accept-invite.spec.js Normal file
View file

@ -0,0 +1,170 @@
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,3 +53,116 @@ test.describe('Admin events access control', () => {
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,4 +66,68 @@ test.describe("Admin members page", () => {
adminPage.getByPlaceholder("email@example.com"),
).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

@ -0,0 +1,111 @@
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()
})
})

65
e2e/admin-series.spec.js Normal file
View file

@ -0,0 +1,65 @@
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

@ -0,0 +1,85 @@
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,13 +1,34 @@
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('page loads for authenticated member', async ({ memberPage }) => {
test('page loads for authenticated member', async ({ browser }) => {
const { context, page: memberPage } = await newMemberPage(browser)
try {
await memberPage.goto('/board')
await expect(memberPage.getByRole('heading', { name: 'Board' })).toBeVisible({ timeout: 15000 })
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 ({ memberPage }) => {
test('clicking New Post reveals the form', async ({ browser }) => {
const { context, page: memberPage } = await newMemberPage(browser)
try {
await memberPage.goto('/board')
await memberPage.waitForLoadState('networkidle')
await expect(memberPage.getByRole('button', { name: '+ New Post' }).first()).toBeVisible({
@ -19,11 +40,16 @@ test.describe('Board page', () => {
await expect(memberPage.getByRole('heading', { name: 'New post' })).toBeVisible()
await expect(memberPage.locator('#post-title')).toBeVisible()
await expect(memberPage.locator('#post-seeking')).toBeVisible()
} finally {
await context.close()
}
})
test('tags drawer toggles open and closed', async ({ memberPage }) => {
test('tags drawer toggles open and closed', async ({ browser }) => {
const { context, page: memberPage } = await newMemberPage(browser)
try {
await memberPage.goto('/board')
await expect(memberPage.getByRole('heading', { name: 'Board' })).toBeVisible({ timeout: 15000 })
await expect(memberPage.getByRole('heading', { name: 'Bulletin Board' })).toBeVisible({ timeout: 15000 })
const drawerToggle = memberPage.getByRole('button', { name: /^Tags\.\.\./ })
// Drawer toggle only appears if cooperative tags exist — skip quietly if not
@ -37,9 +63,14 @@ test.describe('Board page', () => {
await drawerToggle.click()
await expect(memberPage.getByText('Filter:')).not.toBeVisible()
} finally {
await context.close()
}
})
test('create, edit, and delete own post', async ({ memberPage }) => {
test('create, edit, and delete own post', async ({ browser }) => {
const { context, page: memberPage } = await newMemberPage(browser)
try {
await memberPage.goto('/board')
await memberPage.waitForLoadState('networkidle')
await expect(memberPage.getByRole('button', { name: '+ New Post' }).first()).toBeVisible({
@ -85,5 +116,8 @@ test.describe('Board page', () => {
await expect(memberPage.getByRole('heading', { name: editedTitle })).not.toBeVisible({
timeout: 10000,
})
} finally {
await context.close()
}
})
})

View file

@ -67,3 +67,128 @@ test.describe('Events list page', () => {
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,36 +1,32 @@
/**
* Login helpers using dev endpoints.
* These set real httpOnly JWT cookies so all middleware works naturally.
*/
/**
* 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.
*
* 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.
*/
export async function loginAsAdmin(page) {
await page.goto('/api/dev/test-login', { waitUntil: 'domcontentloaded' })
// The endpoint sets the cookie and redirects to /admin.
// waitForURL fires as soon as the URL changes — not when JS finishes.
// waitForLoadState('networkidle') ensures the auth-init plugin and admin
// middleware have both completed their checkMemberStatus() calls before
// 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/)
const res = await page.context().request.get('/api/dev/test-login', { maxRedirects: 0 })
if (res.status() !== 302) {
throw new Error(`/api/dev/test-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/test-login did not set auth-token cookie')
}
}
/**
* Login as a specific member by email via the dev member-login endpoint.
*/
export async function loginAsMember(page, email) {
await page.goto(`/api/dev/member-login?email=${encodeURIComponent(email)}`, { waitUntil: 'domcontentloaded' })
await page.waitForURL(/\/member\//)
const res = await page.context().request.get(
`/api/dev/member-login?email=${encodeURIComponent(email)}`,
{ 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,6 +104,104 @@ test.describe('Join page — member signup flow', () => {
).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 }) => {
const duplicateEmail = `test-e2e-dup-${Date.now()}@example.com`

View file

@ -1,103 +1,222 @@
// Spec: docs/specs/wave-based-slack-onboarding.md
// 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 { 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.skip('shows note for active member without Slack (7.1)', async () => {
// TODO: seed a member { status: 'active', slackInvited: false }, sign in,
// navigate to /member/dashboard, assert the one-liner is visible:
// await expect(page.getByText(/within 2.3 weeks/i)).toBeVisible()
test('shows note for active member without Slack (7.1)', async ({ browser }) => {
const context = await browser.newContext()
const page = await context.newPage()
await loginAsMember(page, 'riley.johnson@cooperativedev.org')
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 () => {
// TODO: same as 7.1 but with slackInvited:true; assert text not present.
// BUG: /api/auth/member does not return slackInvited, so memberData.slackInvited
// 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.skip('hides note for pending_payment member (7.3)', async () => {
// TODO: pending_payment + slackInvited:false; assert text not present.
test('hides note for pending_payment member (7.3)', async ({ browser }) => {
const context = await browser.newContext()
const page = await context.newPage()
await loginAsMember(page, 'jennie@jenniefaber.com')
await page.goto('/member/dashboard')
await expect(page.getByRole('heading', { name: /Welcome.*Jennifer/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 () => {
// TODO: parameterize across statuses { suspended, cancelled, guest }.
// No suspended/cancelled/guest members exist in the dev DB and there is
// 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 ({ adminPage }) => {
await adminPage.goto('/member/dashboard')
const html = await adminPage.content()
expect(html).not.toMatch(/\bwave\b/i)
expect(html).not.toMatch(/\bcohort\b/i)
expect(html).not.toMatch(/\bbatch\b/i)
test.skip('copy contains no wave/cohort/batch language (7.5)', async () => {
// The shipped UI uses the phrase "monthly onboarding waves" — this test's
// \bwave\b assertion contradicts the current copy. Resolve the spec/UI
// divergence before unskipping.
})
test.skip('renders as plain text — no banner / modal / callout styling (7.6)', async () => {
// TODO: assert the note's container is not a UAlert / modal / heavy callout
// (e.g. no .alert, no role="dialog" wrapper).
test('renders as plain text — no banner / modal / callout styling (7.6)', async ({ browser }) => {
const context = await browser.newContext()
const page = await context.newPage()
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.skip('SSR renders without auth — note absent (7.7)', async ({ browser }) => {
test('SSR renders without auth — note absent (7.7)', async ({ browser }) => {
const context = await browser.newContext()
const page = await context.newPage()
const response = await page.goto('/member/dashboard')
const ssrHtml = await response.text()
expect(ssrHtml).not.toMatch(/within 2.3 weeks/i)
expect(ssrHtml).not.toMatch(SLACK_NOTE_RE)
await context.close()
})
test.skip('copy matches approved wording (7.8)', async () => {
// TODO: replace with the final approved string once the Open Question is resolved.
// Awaiting resolution of the Open Question on the final approved string.
})
})
test.describe('Admin members — Slack-invited control (§6)', () => {
test.skip('shows "Mark as Slack invited" for slackInvited:false (6.1)', async ({ adminPage }) => {
test('shows "Mark as Slack invited" for slackInvited:false (6.1)', async ({ adminPage }) => {
await adminPage.goto('/admin/members')
// TODO: locate a row for a member with slackInvited:false and assert the
// button is visible.
// await expect(adminPage.getByRole('button', { name: /Mark as Slack invited/i })).toBeVisible()
await expect(adminPage.getByRole('heading', { name: 'Members' })).toBeVisible()
await expect(
adminPage.getByRole('button', { name: /Mark as Slack invited/i }).first()
).toBeVisible()
})
test.skip('replaces button with "Invited <date>" once flipped (6.2)', async () => {
// TODO: click the button on a row; assert button is gone, date string visible.
// BUG: in admin/members/index.vue, markSlackInvited does
// 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.skip('click triggers single PATCH and updates row in place (6.4)', async ({ adminPage }) => {
// TODO: spy on network for /api/admin/members/*/slack-status; click button;
// assert single PATCH, success, no full-page reload.
})
test('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
// is bumped whenever auth.spec.js's logout test runs in parallel, which
// 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}`
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)
})
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 })
test.skip('member detail page mirrors list controls (6.6)', async () => {
// TODO: navigate to /admin/members/<id>; assert button + date display.
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(),
},
}),
})
})
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 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 }) => {
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()
expect(html).not.toMatch(/slackInviteStatus/)
expect(html).not.toMatch(/Slack:\s*Pending/i)
})
test.skip('UI rolls back on PATCH error — no false "Invited" badge (6.8)', async () => {
// TODO: mock the endpoint to return 500; assert the row stays in
// "Not yet invited" state.
test('member detail page mirrors list controls (6.6)', async ({ adminPage }) => {
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()
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 () => {
// TODO: dependent on Open Question — wire up if implemented.
// Dependent on Open Question — wire up if implemented.
})
})

View file

@ -7,10 +7,10 @@ export default defineConfig({
testDir: "./e2e",
outputDir: "e2e/test-results",
snapshotDir: "e2e/__screenshots__",
fullyParallel: true,
fullyParallel: false,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 1 : 0,
workers: process.env.CI ? 1 : undefined,
retries: process.env.CI ? 1 : 1,
workers: process.env.CI ? 1 : 4,
reporter: "html",
timeout: 60000,
use: {

View file

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

View file

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

View file

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

View file

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