/** * AI Software Factory — E2E: Confluence P2 T-158 (activity feeds) * * Covers spec §14's "User activity feed and space activity feed" on the * done + integration-tested API (permission-scoped `audit_logs` projections, * A-007): * * - User feed (/activity): creating a page in one of the demo user's * workspaces lands a row with the resolved actor + a human verb phrase; * the row deep-links to the page and clicking navigates. * - Space feed (/w/:ws/activity): the same row appears on the workspace's * activity page; clicking navigates to the page. * - No-leak gate: a non-member's session gets 403 from the space feed API; * on the web the [workspace] layout bounces them to their own workspace * (the house pattern shared with /search, /ask, /inbox) — zero rows. * * Selector convention (new surface, no pinned ids to preserve): * data-testid="activity-feed" the row list * data-testid="activity-item" a row (an ; href = deep link) * data-testid="activity-actor" the resolved actor name * data-testid="activity-load-more" the keyset-cursor "Load more" */ import { test, expect } from "../fixtures"; import { createPage } from "../utils/api"; import { waitForCondition } from "../utils/wait"; interface ActivityItem { id: string; action: string; resourceType: string; resourceId: string; workspaceId: string; pageId: string | null; actorName?: string | null; } /** True once `GET /users/me/activity` has a row for the given page (the * audit write is synchronous on page create; this only guards timing). */ async function userFeedHasPage( api: { get: (u: string) => Promise<{ json: () => Promise }> }, pageId: string, ): Promise { const res = await api.get("/api/v1/users/me/activity"); const body = (await res.json()) as { data: ActivityItem[] }; return (body.data ?? []).some((r) => r.resourceType === "page" && r.resourceId === pageId); } async function spaceFeedHasPage( api: { get: (u: string) => Promise<{ json: () => Promise }> }, workspaceId: string, pageId: string, ): Promise { const res = await api.get(`/api/v1/workspaces/${workspaceId}/activity`); const body = (await res.json()) as { data: ActivityItem[] }; return (body.data ?? []).some((r) => r.resourceType === "page" && r.resourceId === pageId); } test.describe("Confluence P2 — activity feeds (T-158)", () => { test("user feed: a created page lands as a row and deep-links to it", async ({ authPage, api, freshWorkspace, }) => { const ws = await freshWorkspace("Activity Feed Space"); const page = await createPage(api, ws.id, { title: "Activity Probe Plan" }); // The audit row is written synchronously by the API — poll to be safe. await waitForCondition( authPage, () => userFeedHasPage(api, page.id), { timeoutMs: 10_000, message: "page.created row did not land in the user feed" }, ); await authPage.goto("/activity"); // The row shows no page title, so target it by its deep link. const target = authPage .locator(`[data-testid="activity-item"][href="/w/${ws.id}/page/${page.id}"]`); await expect(target, "created-page row with deep link").toBeVisible({ timeout: 10_000 }); // Actor + human verb phrase (not the machine action name). await expect(target.locator('[data-testid="activity-actor"]')).toHaveText("Demo User"); await expect(target).toContainText("created a page"); // Clicking navigates to the page. await target.click(); await authPage.waitForURL(/\/page\//, { timeout: 10_000 }); expect(authPage.url()).toContain(`/page/${page.id}`); }); test("space feed: the row appears on the workspace activity page and navigates", async ({ authPage, api, freshWorkspace, }) => { const ws = await freshWorkspace("Space Activity Space"); const page = await createPage(api, ws.id, { title: "Space Activity Probe" }); await waitForCondition( authPage, () => spaceFeedHasPage(api, ws.id, page.id), { timeoutMs: 10_000, message: "page.created row did not land in the space feed" }, ); await authPage.goto(`/w/${ws.id}/activity`); const target = authPage .locator(`[data-testid="activity-item"][href="/w/${ws.id}/page/${page.id}"]`); await expect(target, "created-page row on the space feed").toBeVisible({ timeout: 10_000 }); await expect(target.locator('[data-testid="activity-actor"]')).toHaveText("Demo User"); await expect(target).toContainText("created a page"); await target.click(); await authPage.waitForURL(/\/page\//, { timeout: 10_000 }); expect(authPage.url()).toContain(`/page/${page.id}`); }); test("no-leak: a non-member gets 403 (API) and is bounced off the route (web)", async ({ authPage, api, secondUser, freshWorkspace, }) => { const ws = await freshWorkspace("Gated Activity Space"); const page = await createPage(api, ws.id, { title: "Gated Probe" }); await waitForCondition(authPage, () => spaceFeedHasPage(api, ws.id, page.id), { timeoutMs: 10_000 }); // A REAL second user (registered + logged in by the fixture) with NO // membership in this workspace. Their OWN (default) workspace is where // the house layout bounce lands them. const { page: secondPage, api: secondApi } = await secondUser(); const mineBody = (await ( await secondApi.get("/api/v1/workspaces") ).json()) as { data?: Array<{ id: string }> }; const mineList = mineBody.data ?? []; expect(mineList.length, "second user has a home workspace").toBeGreaterThan(0); const mine = mineList[0]!; // API: 403 — the row must not be readable (no-leak contract). const res = await secondApi.get(`/api/v1/workspaces/${ws.id}/activity`); expect(res.status()).toBe(403); // Web: the [workspace] layout bounces a non-member to their default // workspace — the gated space's rows must never render. await secondPage.goto(`/w/${ws.id}/activity`); await secondPage.waitForURL(`**/w/${mine?.id}**`, { timeout: 15_000 }); await expect(secondPage.locator('[data-testid="activity-item"]')).toHaveCount(0); }); });