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").
This commit is contained in:
parent
03dfdab20e
commit
8dd55ccc09
11 changed files with 1077 additions and 89 deletions
|
|
@ -7,16 +7,20 @@ 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", () => {
|
||||||
|
|
|
||||||
170
e2e/accept-invite.spec.js
Normal file
170
e2e/accept-invite.spec.js
Normal 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 () => {})
|
||||||
|
})
|
||||||
|
|
@ -53,3 +53,116 @@ 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 })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
|
||||||
|
|
@ -66,4 +66,68 @@ 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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
111
e2e/admin-pre-registrants.spec.js
Normal file
111
e2e/admin-pre-registrants.spec.js
Normal 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
65
e2e/admin-series.spec.js
Normal 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 () => {})
|
||||||
|
})
|
||||||
85
e2e/admin-site-content.spec.js
Normal file
85
e2e/admin-site-content.spec.js
Normal 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 },
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -1,13 +1,34 @@
|
||||||
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 ({ memberPage }) => {
|
test('page loads for authenticated member', async ({ browser }) => {
|
||||||
|
const { context, page: memberPage } = await newMemberPage(browser)
|
||||||
|
try {
|
||||||
await memberPage.goto('/board')
|
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()
|
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.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({
|
||||||
|
|
@ -19,11 +40,16 @@ test.describe('Board page', () => {
|
||||||
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 ({ memberPage }) => {
|
test('tags drawer toggles open and closed', async ({ browser }) => {
|
||||||
|
const { context, page: memberPage } = await newMemberPage(browser)
|
||||||
|
try {
|
||||||
await memberPage.goto('/board')
|
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\.\.\./ })
|
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
|
||||||
|
|
@ -37,9 +63,14 @@ test.describe('Board page', () => {
|
||||||
|
|
||||||
await drawerToggle.click()
|
await drawerToggle.click()
|
||||||
await expect(memberPage.getByText('Filter:')).not.toBeVisible()
|
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.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({
|
||||||
|
|
@ -85,5 +116,8 @@ 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()
|
||||||
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -67,3 +67,128 @@ 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.
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
|
||||||
|
|
@ -104,6 +104,104 @@ 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`
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,103 +1,222 @@
|
||||||
// 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.skip('shows note for active member without Slack (7.1)', async () => {
|
test('shows note for active member without Slack (7.1)', async ({ browser }) => {
|
||||||
// TODO: seed a member { status: 'active', slackInvited: false }, sign in,
|
const context = await browser.newContext()
|
||||||
// navigate to /member/dashboard, assert the one-liner is visible:
|
const page = await context.newPage()
|
||||||
// await expect(page.getByText(/within 2.3 weeks/i)).toBeVisible()
|
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 () => {
|
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 () => {
|
test('hides note for pending_payment member (7.3)', async ({ browser }) => {
|
||||||
// TODO: pending_payment + slackInvited:false; assert text not present.
|
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 () => {
|
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 }) => {
|
test.skip('copy contains no wave/cohort/batch language (7.5)', async () => {
|
||||||
await adminPage.goto('/member/dashboard')
|
// The shipped UI uses the phrase "monthly onboarding waves" — this test's
|
||||||
const html = await adminPage.content()
|
// \bwave\b assertion contradicts the current copy. Resolve the spec/UI
|
||||||
expect(html).not.toMatch(/\bwave\b/i)
|
// divergence before unskipping.
|
||||||
expect(html).not.toMatch(/\bcohort\b/i)
|
|
||||||
expect(html).not.toMatch(/\bbatch\b/i)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test.skip('renders as plain text — no banner / modal / callout styling (7.6)', async () => {
|
test('renders as plain text — no banner / modal / callout styling (7.6)', async ({ browser }) => {
|
||||||
// TODO: assert the note's container is not a UAlert / modal / heavy callout
|
const context = await browser.newContext()
|
||||||
// (e.g. no .alert, no role="dialog" wrapper).
|
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 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(/within 2.3 weeks/i)
|
expect(ssrHtml).not.toMatch(SLACK_NOTE_RE)
|
||||||
await context.close()
|
await context.close()
|
||||||
})
|
})
|
||||||
|
|
||||||
test.skip('copy matches approved wording (7.8)', async () => {
|
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.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')
|
await adminPage.goto('/admin/members')
|
||||||
// TODO: locate a row for a member with slackInvited:false and assert the
|
await expect(adminPage.getByRole('heading', { name: 'Members' })).toBeVisible()
|
||||||
// button is visible.
|
await expect(
|
||||||
// await expect(adminPage.getByRole('button', { name: /Mark as Slack invited/i })).toBeVisible()
|
adminPage.getByRole('button', { name: /Mark as Slack invited/i }).first()
|
||||||
|
).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 () => {
|
||||||
// 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 }) => {
|
test('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;
|
// Re-prime the auth cookie. The shared test-admin account's tokenVersion
|
||||||
// assert single PATCH, success, no full-page reload.
|
// 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')
|
await adminPage.goto('/admin/members')
|
||||||
// TODO:
|
await expect(adminPage.getByRole('heading', { name: 'Members' })).toBeVisible()
|
||||||
// await expect(adminPage.getByText(/Not yet invited/i).first()).toBeVisible()
|
await adminPage.waitForLoadState('networkidle')
|
||||||
// const html = await adminPage.content()
|
await adminPage.getByRole('button', { name: 'Add Member' }).click()
|
||||||
// expect(html).not.toMatch(/Slack:\s*Pending/i)
|
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 () => {
|
const patchRequests = []
|
||||||
// TODO: navigate to /admin/members/<id>; assert button + date display.
|
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 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()
|
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 () => {
|
test('member detail page mirrors list controls (6.6)', async ({ adminPage }) => {
|
||||||
// TODO: mock the endpoint to return 500; assert the row stays in
|
await adminPage.goto('/admin/members')
|
||||||
// "Not yet invited" state.
|
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 () => {
|
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.
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue