Implement notifications + supporting fixes
Adds the notifications system end-to-end (4 types, payload-versioned JSONB, SSE-based live unread badge, /notifications page, mark-read API, fan-out job for activity_published, daily 90-day retention purge). Bell icon in the navbar with unread badge. Side-findings from exercising the change: - Add 6-digit magic code to registration (mirrors login UX, mobile paste-friendly), with `[Register Magic Link]` console line in dev so the code is reachable without a real email transport. - Manual passkey/magic-link toggle on the register form (login already had it). - Restrict ALPN to http/1.1 in HTTPS dev so React Router's singleFetchAction CSRF check (Origin vs. Host) passes — Node doesn't synthesize Host from h2's :authority. Plain HTTP dev unaffected. - Followers/Following routes now use the locked-account rule from the profile route (owner + accepted followers see the list; others 404). Profile page renders the count chips as plain spans for viewers who can't see the lists, so private profiles don't surface dead links. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
10c03643a5
commit
e61179ab27
35 changed files with 1903 additions and 109 deletions
114
e2e/notifications.test.ts
Normal file
114
e2e/notifications.test.ts
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
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();
|
||||
await page.waitForLoadState("networkidle");
|
||||
}
|
||||
|
||||
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.
|
||||
await bPage.getByRole("button", { name: /Mark all read/i }).click();
|
||||
await bPage.waitForLoadState("networkidle");
|
||||
await expect(bPage.getByRole("button", { name: /Mark all read/i })).toBeHidden();
|
||||
|
||||
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.
|
||||
await bPage.goto("/follows/requests");
|
||||
await bPage.getByRole("button", { name: "Approve" }).click();
|
||||
await bPage.waitForLoadState("networkidle");
|
||||
|
||||
// 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();
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue