trails/e2e/notifications.test.ts
Ullrich Schäfer 0306d90de8 Merge /follows/requests into /notifications as a tabbed inbox
Folds the actionable follow-requests surface into the Notifications page
as a Requests tab (alongside the existing Activity tab), so the navbar
exposes a single bell instead of two adjacent inboxes. The Requests tab
shows a count badge for pending rows regardless of read state, while the
bell badge keeps reflecting the unread-notifications count (which already
covers `follow_request_received` rows). The standalone /follows/requests
URL is preserved as a 301 redirect so prior notification deep-links,
emails, and bookmarks still resolve.

Driven by the IA review captured in docs/information-architecture.md.
Specs (notifications, social-follows, journal-landing) are updated in
the same change to reflect the new structure.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 08:24:59 +02:00

119 lines
4.9 KiB
TypeScript

import { test, expect, type CDPSession, type Page } from "./fixtures/test";
async function setupVirtualAuthenticator(cdp: CDPSession) {
await cdp.send("WebAuthn.enable");
const { authenticatorId } = await cdp.send("WebAuthn.addVirtualAuthenticator", {
options: {
protocol: "ctap2",
transport: "internal",
hasResidentKey: true,
hasUserVerification: true,
isUserVerified: true,
},
});
return authenticatorId;
}
async function registerUser(page: Page, email: string, username: string) {
await page.goto("/auth/register");
await expect(page.getByRole("heading", { name: "Register" })).toBeVisible();
await page.getByLabel("Email").fill(email);
await page.getByLabel("Username").fill(username);
await page.getByRole("checkbox").check();
await page.getByRole("button", { name: /Register with Passkey/ }).click();
await expect(page).toHaveURL("/", { timeout: 10000 });
}
async function setProfileVisibility(page: Page, value: "public" | "private") {
await page.goto("/settings");
await page.locator(`input[type=radio][name=profileVisibility][value=${value}]`).check();
await page.getByRole("button", { name: /^Save$/ }).first().click();
// Don't use waitForLoadState("networkidle") — the SSE connection to
// /api/events keeps the network busy indefinitely. Wait for the
// explicit save confirmation instead.
await expect(page.getByText("Profile saved.")).toBeVisible({ timeout: 10000 });
}
test.describe.configure({ mode: "serial" });
test.describe("Notifications", () => {
test("anonymous /notifications redirects to login", async ({ page }) => {
await page.goto("/notifications");
await expect(page).toHaveURL(/\/auth\/login/, { timeout: 10000 });
});
test("public auto-accept follow → recipient sees follow_received notification", async ({ page, browser }) => {
const cdp = await page.context().newCDPSession(page);
await setupVirtualAuthenticator(cdp);
const stamp = Date.now();
const aEmail = `nf-a-${stamp}@example.com`;
const aUsername = `nfa${stamp}`;
const bEmail = `nf-b-${stamp}@example.com`;
const bUsername = `nfb${stamp}`;
await registerUser(page, aEmail, aUsername);
const bCtx = await browser.newContext();
const bPage = await bCtx.newPage();
const bCdp = await bPage.context().newCDPSession(bPage);
await setupVirtualAuthenticator(bCdp);
await registerUser(bPage, bEmail, bUsername);
await setProfileVisibility(bPage, "public");
// A follows B (auto-accept).
await page.goto(`/users/${bUsername}`);
await page.getByRole("button", { name: "Follow" }).click();
await expect(page.getByRole("button", { name: "Unfollow" })).toBeVisible({ timeout: 5000 });
// B opens /notifications — should see one row referencing A.
await bPage.goto("/notifications");
await expect(bPage.getByText(new RegExp(`${aUsername}.+started following`))).toBeVisible({ timeout: 10000 });
// Mark all read clears the unread badge. The button disappears when
// hasUnread flips to false; the toBeHidden assertion polls.
await bPage.getByRole("button", { name: /Mark all read/i }).click();
await expect(bPage.getByRole("button", { name: /Mark all read/i })).toBeHidden({ timeout: 10000 });
await bCtx.close();
});
test("private profile: request → approve → follower sees follow_request_approved notification", async ({ page, browser }) => {
const cdp = await page.context().newCDPSession(page);
await setupVirtualAuthenticator(cdp);
const stamp = Date.now();
const aEmail = `nfap-a-${stamp}@example.com`;
const aUsername = `nfapa${stamp}`;
const bEmail = `nfap-b-${stamp}@example.com`;
const bUsername = `nfapb${stamp}`;
await registerUser(page, aEmail, aUsername);
const bCtx = await browser.newContext();
const bPage = await bCtx.newPage();
const bCdp = await bPage.context().newCDPSession(bPage);
await setupVirtualAuthenticator(bCdp);
await registerUser(bPage, bEmail, bUsername);
// B stays private (default).
// A requests to follow B.
await page.goto(`/users/${bUsername}`);
await page.getByRole("button", { name: /Request to follow/i }).click();
await expect(page.getByRole("button", { name: /Requested/i })).toBeVisible({ timeout: 5000 });
// B approves the request from the Requests tab on /notifications.
// After the fetcher.Form revalidates, the empty-state copy
// replaces the request row.
await bPage.goto("/notifications?tab=requests");
await bPage.getByRole("button", { name: "Approve" }).click();
await expect(bPage.getByText(/No pending follow requests/i)).toBeVisible({ timeout: 10000 });
// A reloads /notifications — should see follow_request_approved
// referencing B (the target's username).
await page.goto("/notifications");
await expect(page.getByText(new RegExp(`${bUsername}.+accepted your follow request`))).toBeVisible({ timeout: 10000 });
await bCtx.close();
});
});