familynido/web/familynido-web/e2e/invitations.spec.ts
Pablo Fernández a308041d59 Initial commit
FamilyNido — a self-hosted PWA for a single household: shared calendar,
chores, meals, school agenda, health records and a family wall. One
instance per family, deployable with `docker compose` on any home
server.

Stack: .NET 10 (ASP.NET Core Minimal APIs) + EF Core 10 + PostgreSQL 16
on the backend, Angular 21 (standalone, signals, zoneless) + Tailwind
CSS v4 on the frontend, SignalR for realtime, optional OIDC alongside
local credentials, integration via a versioned `/api/v1/**` public API.

See README.md for the module overview and how to deploy.
2026-05-13 00:23:14 +02:00

71 lines
3.6 KiB
TypeScript

import { expect, test } from '@playwright/test';
/**
* Onboarding wiring: as admin A, add a new family member with email +
* "send invitation" checked, verify the invitation banner mounts with a
* copy-link, and then clean up so the suite stays idempotent — revoke
* the pending invitation, then permanently delete the member from its
* detail page (a member with a pending invitation can't be deleted, so
* the order matters).
*
* Email delivery is disabled in the test environment, so this exercises
* the row-creation path without actually sending anything.
*/
test('admin invites a new member; revoke + delete cleans up after', async ({ page }) => {
// Both the revoke action and the member-detail "Eliminar" use
// window.confirm — accept everything that fires during this spec.
page.on('dialog', (dialog) => dialog.accept());
const ts = Date.now();
const memberName = `E2E Invite ${ts}`;
const memberEmail = `e2e-invite-${ts}@familynido.test`;
// ── A: open /nido and submit the add-member form ───────────────────────
await page.goto('/nido');
// The round "+" button uses aria-label="Añadir miembro".
await page.getByRole('button', { name: /^añadir miembro$/i }).click();
// Labels on this form aren't wired with `for=`, so target inputs by
// type/required attributes within the visible <form>.
const form = page.locator('form');
await form.locator('input[type="text"][required]').fill(memberName);
await form.locator('input[type="email"]').fill(memberEmail);
// The "Enviar invitación por email" checkbox is hidden until both
// memberType=Adult and contactEmail are present. Default is unchecked
// — we tick it so the backend mints an Invitation row alongside the
// FamilyMember.
await form.getByRole('checkbox', { name: /enviar invitación por email/i }).check();
await form.getByRole('button', { name: /^guardar$/i }).click();
// ── Assert the invitation banner mounted with a /invite/<token> link ──
const banner = page.locator('text=Invitación creada para').locator('xpath=ancestor::div[1]');
await expect(banner).toBeVisible();
const linkInput = banner.locator('input[readonly]');
const copyLink = await linkInput.inputValue();
// The copy-link must be built from Email:AppBaseUrl (set by the
// workflow to the SPA host) and target the Angular `/invite/:token`
// route. The token is a URL-safe-base64 segment ≥20 chars.
const baseUrl = process.env['E2E_BASE_URL'] ?? 'http://localhost:5173';
expect(copyLink.startsWith(`${baseUrl}/invite/`)).toBe(true);
expect(copyLink).toMatch(/[A-Za-z0-9._-]{20,}$/);
// ── Cleanup #1: revoke the pending invitation ─────────────────────────
// Pending invitations live in their own list at the bottom of /nido.
// Scope to the <li> that mentions our email so we revoke the right one.
const pendingRow = page.locator('li').filter({ hasText: memberEmail });
await pendingRow.getByRole('button', { name: /^revocar$/i }).click();
// After refresh, the pending row is gone.
await expect(pendingRow).toHaveCount(0);
// ── Cleanup #2: delete the member from its detail page ────────────────
await page.locator('a').filter({ hasText: memberName }).first().click();
await page.getByRole('button', { name: /^eliminar$/i }).click();
// Successful delete redirects back to /nido; the member is gone.
await page.waitForURL(/\/nido(\/|\?|$)/);
await expect(page.locator('a').filter({ hasText: memberName })).toHaveCount(0);
});