import { expect, test } from "@playwright/test"; import { collectFailedApiRequests, confirmSlide, expectNoFailedApiRequests, mockConfig, seedAuthStorage, } from "./helpers/e2e"; test("join household modal accepts invite links but rejects legacy invite codes", async ({ page }) => { await seedAuthStorage(page, { username: "manager-user" }); await mockConfig(page); await page.route("**/households", async (route) => { await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify([]), }); }); await page.route("**/api/invite-links/approvaltoken", async (route) => { await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ link: { token: "approvaltoken", status: "ACTIVE", viewerStatus: null, active_policy: "APPROVAL_REQUIRED", group_name: "Approval Home", }, }), }); }); await page.goto("/manage"); await page.getByRole("button", { name: "Join Household", exact: true }).click(); await page.getByLabel("Invite Link").fill("HABC123"); await page.getByRole("button", { name: "Open Invite" }).click(); await expect(page.locator(".create-join-modal .error-message")).toHaveText( "Use a household invite link like /invite/abcd1234." ); await page.getByLabel("Invite Link").fill("/invite/approvaltoken"); await page.getByRole("button", { name: "Open Invite" }).click(); await expect(page).toHaveURL(/\/invite\/approvaltoken$/); await expect(page.getByRole("heading", { name: "Join Approval Home" })).toBeVisible(); }); test("household management shows pending invite approvals and can approve them", async ({ page }) => { await seedAuthStorage(page, { username: "manager-user" }); await mockConfig(page); let members = [ { id: 1, username: "manager-user", role: "owner" }, ]; let pendingRequests = [ { id: 41, user_id: 7, username: "pending-pal", name: "Pending Pal", display_name: "", created_at: "2026-03-31T12:00:00.000Z", status: "PENDING", }, ]; await page.route("**/households", async (route) => { await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify([{ id: 1, name: "Approval Home", role: "owner", invite_code: "ABCD1234" }]), }); }); await page.route("**/households/1/members", async (route) => { await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(members), }); }); await page.route("**/api/groups/join-policy", async (route) => { const request = route.request(); if (request.method() === "GET") { await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ joinPolicy: "APPROVAL_REQUIRED" }), }); return; } await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ ok: true }), }); }); await page.route("**/api/groups/invites", async (route) => { const request = route.request(); if (request.method() === "GET") { await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ links: [ { id: 9, token: "invite-token-1234", policy: "APPROVAL_REQUIRED", single_use: false, expires_at: "2030-01-01T00:00:00.000Z", used_at: null, revoked_at: null, }, ], }), }); return; } await route.fulfill({ status: 201, contentType: "application/json", body: JSON.stringify({ link: { id: 10, token: "new-token" } }), }); }); await page.route("**/api/groups/join-requests", async (route) => { await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ requests: pendingRequests }), }); }); await page.route("**/api/groups/join-requests/decision", async (route) => { const body = route.request().postDataJSON() as { requestId?: number; decision?: string }; if (body.requestId === 41 && body.decision === "APPROVE") { pendingRequests = []; members = [ ...members, { id: 7, username: "pending-pal", role: "member" }, ]; } await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ request: { id: 41, user_id: 7, status: body.decision === "APPROVE" ? "APPROVED" : "DENIED", }, }), }); }); await page.goto("/manage?tab=household"); await expect(page.getByRole("heading", { name: "Invite Links" })).toBeVisible(); await expect(page.getByText("Pending Pal")).toBeVisible(); await expect(page.getByText("Invite Code")).toHaveCount(0); await page.getByRole("button", { name: "Approve" }).click(); await expect(page.locator(".action-toast.action-toast-success")).toContainText("Approved join request"); await expect(page.locator(".action-toast.action-toast-success")).toContainText("Pending Pal"); await expect(page.getByText("No pending join requests right now.")).toBeVisible(); await expect(page.getByText("Members (2)")).toBeVisible(); }); test("household owner can transfer ownership from household settings", async ({ page }) => { const failedApiRequests = collectFailedApiRequests(page); await seedAuthStorage(page, { role: "owner", username: "manager-user" }); await mockConfig(page); let households = [{ id: 1, name: "Approval Home", role: "owner", invite_code: "ABCD1234" }]; let members = [ { id: 1, username: "manager-user", role: "owner" }, { id: 2, username: "nico-admin", role: "admin" }, ]; await page.route("**/households", async (route) => { await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(households), }); }); await page.route("**/households/1/members", async (route) => { await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(members), }); }); await page.route("**/households/1/members/*/role", async (route) => { const request = route.request(); if (request.method() !== "PATCH") { await route.fulfill({ status: 405 }); return; } const body = request.postDataJSON() as { role?: string }; if (body.role === "owner") { households = [{ id: 1, name: "Approval Home", role: "admin", invite_code: "ABCD1234" }]; members = [ { id: 1, username: "manager-user", role: "admin" }, { id: 2, username: "nico-admin", role: "owner" }, ]; } await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ message: "Household ownership transferred successfully", member: { user_id: 2, role: body.role || "member" }, }), }); }); await page.route("**/api/groups/join-policy", async (route) => { const request = route.request(); if (request.method() === "GET") { await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ joinPolicy: "APPROVAL_REQUIRED" }), }); return; } await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ ok: true }), }); }); await page.route("**/api/groups/invites", async (route) => { const request = route.request(); if (request.method() === "GET") { await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ links: [] }), }); return; } await route.fulfill({ status: 201, contentType: "application/json", body: JSON.stringify({ link: { id: 10, token: "new-token" } }), }); }); await page.route("**/api/groups/join-requests", async (route) => { await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ requests: [] }), }); }); await page.goto("/manage?tab=household"); await expect(page.getByRole("button", { name: "Make Owner" })).toBeVisible(); await page.getByRole("button", { name: "Make Owner" }).click(); await expect(page.getByText("Transfer ownership to nico-admin?")).toBeVisible(); await confirmSlide(page); await expect(page.locator(".action-toast.action-toast-success")).toContainText("Transferred household ownership"); await expect(page.locator(".action-toast.action-toast-success")).toContainText("nico-admin"); await expect(page.getByText("👑 Owner")).toContainText("Owner"); await expect(page.getByText("🛠️ Admin")).toContainText("Admin"); await expect(page.getByRole("button", { name: "Make Owner" })).toHaveCount(0); expectNoFailedApiRequests(failedApiRequests); });