/** * AI Software Factory — E2E: Workspace (Phase 2) * * Covers: create workspace (sidebar switcher), invite a member (API) + accept * (second context), role visibility, rename, delete, settings tabs render. * * Selector convention (tests/README.md): * data-testid="workspace-switcher" sidebar workspace switcher menu * data-testid="workspace-switcher-new" "Create workspace" item * data-testid="workspace-dialog" create/rename workspace dialog * data-testid="workspace-dialog-name" name input in the dialog * data-testid="workspace-dialog-submit" save button in the dialog * data-testid="workspace-settings" workspace settings page * data-testid="settings-tab-{profile|billing|security|integrations}" * data-testid="member-row-{userId}" member row in settings * data-testid="member-role-{userId}" role badge on the member row */ import { test, expect } from "../fixtures"; import { addMemberViaSeam, renameWorkspace, deleteWorkspace, listMembers, seamServiceToken, } from "../utils/api"; test.describe("Workspace — Phase 2", () => { test("create workspace from sidebar switcher", async ({ authPage, freshWorkspace, }) => { const ws = await freshWorkspace("My New Team Space"); // The new workspace shows up in the sidebar switcher. await authPage.goto("/"); await expect(authPage.getByTestId("sidebar")).toBeVisible(); await authPage.getByTestId("workspace-switcher").click(); await expect( authPage.getByTestId("workspace-switcher").getByText("My New Team Space"), ).toBeVisible({ timeout: 10_000 }); // Switching into it lands on its home (an empty-state view, not 404). await authPage.getByText("My New Team Space").click(); await authPage.waitForURL(/\/w\//, { timeout: 10_000 }); }); test("add member (test seam) + second context; role visible", async ({ api, secondUser, freshWorkspace, }) => { const ws = await freshWorkspace("Invited Space"); // 1) A REAL second user registers + logs in (via the fixture). const { user: second, page: secondPage } = await secondUser(); // 2) The second user is added to the workspace through the internal test // seam (TASK-0095 S9 — the D3 unblock). The public invite flow hashes // the token and emails it, so a test cannot complete it without a mail // server; the seam (service-token guarded, e2e-only) adds the member // directly. Membership is DB-authorized, so the second user's own // session (step 4's browser context) can immediately see the // workspace. await addMemberViaSeam(seamServiceToken(), ws.id, second.email, "MEMBER"); // 3) The inviter sees the new member (role MEMBER) in the list, and the // demo user is still OWNER. const members = await listMembers(api, ws.id); const owner = members.find((m) => m.role === "OWNER"); expect(owner, "demo user must be OWNER of a workspace they created").toBeTruthy(); const joined = members.find((m) => m.email === second.email); expect(joined, "second user must be a member after the seam add").toBeTruthy(); expect(joined?.role).toBe("MEMBER"); expect(members.length).toBeGreaterThanOrEqual(2); // 4) The member's UI shows the role badge on the settings page. await secondPage.goto(`/w/${ws.id}/settings`); await expect( secondPage.getByTestId("workspace-settings"), ).toBeVisible({ timeout: 15_000 }); }); test("rename workspace", async ({ authPage, api, freshWorkspace }) => { const ws = await freshWorkspace("Rename Me"); const renamed = await renameWorkspace(api, ws.id, "Renamed Space"); expect(renamed.name).toBe("Renamed Space"); await authPage.goto(`/w/${ws.id}`); // The sidebar reflects the new name. await expect(authPage.getByTestId("sidebar").getByText("Renamed Space")).toBeVisible({ timeout: 10_000, }); }); test("delete workspace (owner) — pages removed, switcher updated", async ({ authPage, api, freshWorkspace, }) => { const ws = await freshWorkspace("Doomed Space"); // Give it a page so deletion has something to clean up. const pageRes = await api.post(`/api/v1/workspaces/${ws.id}/pages`, { data: { title: "Orphan Candidate" }, }); expect(pageRes.status()).toBe(201); await deleteWorkspace(api, ws.id); // The workspace is gone: GET returns 404. const gone = await api.get(`/api/v1/workspaces/${ws.id}`); expect(gone.status()).toBe(404); await authPage.goto("/"); await authPage.getByTestId("workspace-switcher").click(); // Poll briefly (the sidebar refetches on focus) for the name to be gone. const doomed = authPage.getByTestId("workspace-switcher").getByText("Doomed Space"); const deadline = Date.now() + 10_000; // eslint-disable-next-line no-constant-condition while (true) { const count = await doomed.count(); if (count === 0) break; if (Date.now() > deadline) throw new Error("deleted workspace still in switcher"); await authPage.waitForTimeout(500); } }); test("settings tabs render (profile, billing placeholder, security, integrations)", async ({ authPage, freshWorkspace, }) => { const ws = await freshWorkspace("Settings Space"); await authPage.goto(`/w/${ws.id}/settings`); await expect(authPage.getByTestId("workspace-settings")).toBeVisible({ timeout: 15_000, }); for (const tab of ["profile", "billing", "security", "integrations"]) { const tabEl = authPage.getByTestId(`settings-tab-${tab}`); await expect(tabEl, `settings tab "${tab}" should render`).toBeVisible(); await tabEl.click(); } // Billing + integrations are placeholders in Phase 2 (Phase 14 fills // them); they must render a visible empty/placeholder state, not 404. await expect(authPage.getByTestId("workspace-settings")).toBeVisible(); }); test("member list only shows actual members (tenant isolation)", async ({ api, secondUser, freshWorkspace, }) => { const ws = await freshWorkspace("Isolated Space"); const { user: outsider, api: outsiderApi } = await secondUser(); // The outsider has NO membership in this workspace. const members = await listMembers(api, ws.id); expect(members.some((m) => m.email === outsider.email)).toBe(false); // And the outsider cannot read the member list at all (403/404). const denied = await outsiderApi.get(`/api/v1/workspaces/${ws.id}/members`); expect([403, 404]).toContain(denied.status()); }); });