diff --git a/web/e2e/friends-notifications.spec.ts b/web/e2e/friends-notifications.spec.ts index 96583463..b37e4893 100644 --- a/web/e2e/friends-notifications.spec.ts +++ b/web/e2e/friends-notifications.spec.ts @@ -1,11 +1,15 @@ +import { readFileSync, existsSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import path from "node:path"; import { test, expect, type Page, type Route } from "@playwright/test"; -// Coverage for #39: styling on the friends/pings and notifications screens (src/routes/friends, -// src/routes/notifications). Both are gated on a signed-in session (see CLAUDE.md — friends, pings -// and profile are the only legitimately server-backed features), so this spec has two halves per -// page: a real, unmocked signed-out check (no session cookie at all — proves the sign-in prompt -// renders instead of crashing), and a signed-in check that mocks the Supabase session and REST -// responses so nothing here ever touches the live project. +// Coverage for #39 (styling) and #66 (social activity feed: pings + favorited-food sightings +// merged into one feed on src/routes/notifications, "ping a friend" moved off src/routes/friends). +// Both pages are gated on a signed-in session (see CLAUDE.md — friends, pings and profile are the +// only legitimately server-backed features), so this spec has two halves per page: a real, +// unmocked signed-out check (no session cookie at all — proves the sign-in prompt renders instead +// of crashing), and a signed-in check that mocks the Supabase session and REST responses so nothing +// here ever touches the live project. // // Session mocking: @supabase/ssr's browser client stores its session as a cookie // (`sb--auth-token`, base64url-JSON, see node_modules/@supabase/ssr/dist/main/cookies.js), @@ -22,6 +26,24 @@ import { test, expect, type Page, type Route } from "@playwright/test"; const USER_ID = "11111111-1111-4111-8111-111111111111"; const FRIEND_ID = "22222222-2222-4222-8222-222222222222"; const REQUESTER_ID = "33333333-3333-4333-8333-333333333333"; +const HAMPSHIRE_TID = 3; +const WORCESTER_TID = 1; + +const WEB_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); + +/** Reads PUBLIC_SUPABASE_URL from web/.env (falling back to .env.example, since local runs may + * not have copied it yet — CI's e2e job does `cp web/.env.example web/.env` first) and derives the + * project ref the same way @supabase/supabase-js itself does for its default cookie/storage key + * (see node_modules/@supabase/supabase-js/dist/index.mjs: `sb-${new URL(supabaseUrl).hostname.split(".")[0]}-auth-token`). + * Was previously hardcoded as "your-project-ref" (only correct because that happens to be the + * .env.example placeholder) — deriving it means this spec doesn't silently drift from the client. */ +function supabaseCookieName(): string { + const envPath = existsSync(path.join(WEB_DIR, ".env")) ? path.join(WEB_DIR, ".env") : path.join(WEB_DIR, ".env.example"); + const match = readFileSync(envPath, "utf-8").match(/^PUBLIC_SUPABASE_URL=(.+)$/m); + if (!match) throw new Error(`PUBLIC_SUPABASE_URL not found in ${envPath}`); + const projectRef = new URL(match[1].trim()).hostname.split(".")[0]; + return `sb-${projectRef}-auth-token`; +} function fakeSessionCookie(): [string, string] { const now = Math.floor(Date.now() / 1000); @@ -44,10 +66,10 @@ function fakeSessionCookie(): [string, string] { created_at: new Date().toISOString(), }, }; - return ["sb-your-project-ref-auth-token", "base64-" + Buffer.from(JSON.stringify(session)).toString("base64url")]; + return [supabaseCookieName(), "base64-" + Buffer.from(JSON.stringify(session)).toString("base64url")]; } -type CapturedRequest = { table: string; method: string; url: URL }; +type CapturedRequest = { table: string; method: string; url: URL; body: unknown }; /** Seeds the fake session cookie and wires up a REST mock router + a realtime no-op, so a * signed-in test never reaches the live project. `handlers` maps a `rest/v1/` path segment @@ -63,7 +85,11 @@ async function signInAndMockSupabase(page: Page, handlers: Record { const url = new URL(route.request().url()); const table = url.pathname.replace("/rest/v1/", ""); - requests.push({ table, method: route.request().method(), url }); + const method = route.request().method(); + // postDataJSON() throws on bodyless requests (GET/DELETE-without-body) -- only POST/PATCH here + // ever carry one. + const body = method === "POST" || method === "PATCH" ? route.request().postDataJSON() : null; + requests.push({ table, method, url, body }); const handler = handlers[table]; if (handler) { await handler(route, url); @@ -74,14 +100,35 @@ async function signInAndMockSupabase(page: Page, handlers: Record route.fulfill({ json: {} })); - // friends/+page.svelte opens a realtime channel for the pings inbox -- page.route doesn't cover - // WebSockets, so this closes the connection immediately instead of letting it hit the live + // notifications/+page.svelte opens a realtime channel for the pings inbox -- page.route doesn't + // cover WebSockets, so this closes the connection immediately instead of letting it hit the live // project's realtime endpoint. await page.routeWebSocket(/realtime/, (ws) => ws.close()); return requests; } +/** profiles is queried two different shapes on notifications/+page.svelte: `.eq("user_id", + * me).single()` for the notifications toggle, and `.in("user_id", otherIds)` for friend names. Both + * hit the same `rest/v1/profiles` path, so the mock has to branch on the query string instead of + * the table name. */ +function profilesHandler(single: Record, friendRows: { user_id: string; display_name: string }[] = []) { + return (route: Route, url: URL) => { + const userIdParam = url.searchParams.get("user_id") ?? ""; + if (userIdParam.startsWith("in.")) return route.fulfill({ json: friendRows }); + return route.fulfill({ json: single }); + }; +} + +async function waitForRequest(requests: CapturedRequest[], table: string, method: string): Promise { + await expect + .poll(() => requests.some((r) => r.table === table && r.method === method), { + message: `expected a ${method} to ${table} — click handlers are async and Playwright's .click() doesn't wait for them`, + }) + .toBe(true); + return requests.find((r) => r.table === table && r.method === method)!; +} + test.describe("Friends — signed out", () => { test("shows a styled sign-in prompt instead of the friends UI", async ({ page }) => { await page.goto("/friends"); @@ -96,14 +143,20 @@ test.describe("Friends — signed out", () => { }); test.describe("Notifications — signed out", () => { - test("shows a styled sign-in prompt instead of the notifications UI", async ({ page }) => { + test("shows the feed shell with a sign-in value prop, not a dead end", async ({ page }) => { await page.goto("/notifications"); await expect(page.getByRole("heading", { name: "Notifications", level: 1 })).toHaveClass(/page-title/); const prompt = page.locator(".empty-state"); await expect(prompt).toBeVisible(); - await expect(prompt).toContainText("Sign in to enable favorited-food alerts."); - await expect(page.getByRole("heading", { name: "Sightings" })).toHaveCount(0); + await expect(prompt).toContainText("Sign in to see this feed"); + await expect(prompt).toContainText("pings from friends and favorited-dish alerts"); + // No crash: none of the signed-in-only sections render. + await expect(page.getByRole("heading", { name: "Activity" })).toHaveCount(0); + await expect(page.getByRole("heading", { name: "Ping a friend" })).toHaveCount(0); + // The value prop points at the header's own control -- it must not add a second one (that + // would make every getByRole("button", { name: /Sign in/ }) locator ambiguous). + await expect(page.getByRole("button", { name: /Sign in/ })).toHaveCount(1); }); }); @@ -129,7 +182,6 @@ test.describe("Friends — signed in", () => { { user_id: REQUESTER_ID, display_name: "Riley Requested" }, ], }), - pings: (route) => route.fulfill({ json: [] }), }); await page.goto("/friends"); @@ -147,18 +199,19 @@ test.describe("Friends — signed in", () => { await expect(outgoingRow.getByRole("button", { name: "Accept" })).toHaveCount(0); await acceptButton.click(); - const patch = requests.find((r) => r.table === "friendships" && r.method === "PATCH"); - expect(patch, "Accept should PATCH the friendships row").toBeTruthy(); - expect(patch!.url.searchParams.get("user_a")).toBe(`eq.${friendships[0].user_a}`); - expect(patch!.url.searchParams.get("user_b")).toBe(`eq.${friendships[0].user_b}`); + // Playwright's click() resolves once the click is dispatched, not once acceptFriend()'s async + // PATCH has round-tripped through the mock router -- polling instead of reading requests + // synchronously closes that race (previously ~L149). + const patch = await waitForRequest(requests, "friendships", "PATCH"); + expect(patch.url.searchParams.get("user_a")).toBe(`eq.${friendships[0].user_a}`); + expect(patch.url.searchParams.get("user_b")).toBe(`eq.${friendships[0].user_b}`); }); - test("accepted friends get ping controls in a card, badged distinctly from requests", async ({ page }) => { + test("accepted friends are shown without ping controls -- pinging moved to the activity feed (#66)", async ({ page }) => { const friendship = { user_a: USER_ID < FRIEND_ID ? USER_ID : FRIEND_ID, user_b: USER_ID < FRIEND_ID ? FRIEND_ID : USER_ID, status: "accepted", requested_by: FRIEND_ID }; await signInAndMockSupabase(page, { friendships: (route) => route.fulfill({ json: [friendship] }), profiles: (route) => route.fulfill({ json: [{ user_id: FRIEND_ID, display_name: "Casey Friend" }] }), - pings: (route) => route.fulfill({ json: [{ id: "ping-1", sender_id: FRIEND_ID, receiver_id: USER_ID, hall_tid: 3, message: "meet up?", created_at: new Date().toISOString() }] }), }); await page.goto("/friends"); @@ -166,58 +219,237 @@ test.describe("Friends — signed in", () => { const friendRow = page.locator("li.card", { hasText: "Casey Friend" }); await expect(friendRow.locator(".badge", { hasText: "Friend" })).toBeVisible(); - const pingButton = friendRow.getByRole("button", { name: /Ping/ }); - await expect(pingButton).toHaveClass(/btn-secondary/); - - const inboxEntry = page.locator("li", { hasText: "meet up?" }); - await expect(inboxEntry).toBeVisible(); - await expect(inboxEntry.locator(".badge")).toHaveText("Hampshire"); + await expect(friendRow.getByRole("button", { name: /Ping/ })).toHaveCount(0); + await expect(page.getByRole("heading", { name: "Pings you've received" })).toHaveCount(0); + await expect(page.getByRole("link", { name: "Notifications feed" })).toBeVisible(); }); }); test.describe("Notifications — signed in", () => { test("the alert toggle is visually clear and flips state with the right PATCH", async ({ page }) => { const requests = await signInAndMockSupabase(page, { - profiles: (route) => route.fulfill({ json: { notifications_enabled: false } }), + profiles: profilesHandler({ notifications_enabled: false }), food_sightings: (route) => route.fulfill({ json: [] }), + pings: (route) => route.fulfill({ json: [] }), + friendships: (route) => route.fulfill({ json: [] }), favorited_foods: (route) => route.fulfill({ json: [] }), }); await page.goto("/notifications"); - const badge = page.locator(".badge"); + // Scoped to the toggle's own section rather than ".badge".first() -- the feed below can also + // render "New" badges, and .first() would silently start matching the wrong one if their DOM + // order ever changed instead of failing loudly. + const badge = page.locator("section", { hasText: "Favorited-dish alerts" }).locator(".badge"); await expect(badge).toHaveText("Alerts off", { timeout: 15_000 }); // hydration proof: reflects the mocked profile fetch const toggle = page.getByRole("checkbox"); await toggle.click(); await expect(badge).toHaveText("Alerts on"); - const patch = requests.find((r) => r.table === "profiles" && r.method === "PATCH"); - expect(patch, "toggling should PATCH the profile row").toBeTruthy(); - expect(patch!.url.searchParams.get("user_id")).toBe(`eq.${USER_ID}`); + // Same race as the friendships Accept PATCH above (previously ~L195, unfixed twin of ~L149). + const patch = await waitForRequest(requests, "profiles", "PATCH"); + expect(patch.url.searchParams.get("user_id")).toBe(`eq.${USER_ID}`); }); - test("unread sightings are badged New, hall names are badged, both use the design tokens", async ({ page }) => { + test("feed mixes pings and sightings newest-first, both badged New when unread", async ({ page }) => { + const older = new Date(Date.now() - 60_000).toISOString(); + const newer = new Date().toISOString(); await signInAndMockSupabase(page, { - profiles: (route) => route.fulfill({ json: { notifications_enabled: true } }), + profiles: profilesHandler({ notifications_enabled: true }, [{ user_id: FRIEND_ID, display_name: "Casey Friend" }]), + friendships: (route) => route.fulfill({ json: [{ user_a: USER_ID < FRIEND_ID ? USER_ID : FRIEND_ID, user_b: USER_ID < FRIEND_ID ? FRIEND_ID : USER_ID, status: "accepted" }] }), food_sightings: (route) => + route.fulfill({ json: [{ id: "s1", dish_name: "French Toast", hall_tid: HAMPSHIRE_TID, sighted_date: "2026-08-19", read_at: null, created_at: older }] }), + pings: (route) => route.fulfill({ json: [{ id: "p1", sender_id: FRIEND_ID, hall_tid: WORCESTER_TID, message: "come eat?", created_at: newer }] }), + }); + + await page.goto("/notifications"); + const feed = page.getByRole("list", { name: "Activity feed" }); + const items = feed.getByRole("listitem"); + await expect(items).toHaveCount(2, { timeout: 15_000 }); // hydration proof + + // Newest (the ping) first. + await expect(items.nth(0)).toContainText("Casey Friend"); + await expect(items.nth(0)).toContainText("come eat?"); + await expect(items.nth(1)).toContainText("French Toast"); + + // Both unread: the ping (no last-seen marker yet on a fresh browser context) and the sighting + // (read_at is null). + await expect(items.nth(0).locator(".badge", { hasText: "New" })).toBeVisible(); + await expect(items.nth(1).locator(".badge", { hasText: "New" })).toBeVisible(); + }); + + test("ping a friend from the feed sends the right insert", async ({ page }) => { + const requests = await signInAndMockSupabase(page, { + profiles: profilesHandler({ notifications_enabled: false }, [{ user_id: FRIEND_ID, display_name: "Casey Friend" }]), + friendships: (route) => route.fulfill({ json: [{ user_a: USER_ID < FRIEND_ID ? USER_ID : FRIEND_ID, user_b: USER_ID < FRIEND_ID ? FRIEND_ID : USER_ID, status: "accepted" }] }), + food_sightings: (route) => route.fulfill({ json: [] }), + pings: async (route) => { + if (route.request().method() === "POST") { + return route.fulfill({ json: [{ id: "ping-new", sender_id: USER_ID, receiver_id: FRIEND_ID, hall_tid: HAMPSHIRE_TID, message: "omw", created_at: new Date().toISOString() }] }); + } + return route.fulfill({ json: [] }); + }, + }); + + await page.goto("/notifications"); + await expect(page.getByRole("heading", { name: "Ping a friend" })).toBeVisible({ timeout: 15_000 }); + + await page.getByLabel("Friend").selectOption(FRIEND_ID); + await page.getByLabel("Hall").selectOption(String(HAMPSHIRE_TID)); + await page.getByLabel("Message (optional)").fill("omw"); + await page.getByRole("button", { name: "Send ping" }).click(); + + const post = await waitForRequest(requests, "pings", "POST"); + expect(post.body).toMatchObject({ sender_id: USER_ID, receiver_id: FRIEND_ID, hall_tid: HAMPSHIRE_TID, message: "omw" }); + await expect(page.getByRole("status")).toHaveText("Ping sent"); + }); + + // Review nit on #75: sendPing() previously set pingSent = true unconditionally, without + // checking the insert's result -- an RLS rejection (not actually friends any more, blocked, + // etc.) still showed "Ping sent". + test("a failed ping insert (RLS rejection) shows a failure state, not a false 'Ping sent'", async ({ page }) => { + await signInAndMockSupabase(page, { + profiles: profilesHandler({ notifications_enabled: false }, [{ user_id: FRIEND_ID, display_name: "Casey Friend" }]), + friendships: (route) => route.fulfill({ json: [{ user_a: USER_ID < FRIEND_ID ? USER_ID : FRIEND_ID, user_b: USER_ID < FRIEND_ID ? FRIEND_ID : USER_ID, status: "accepted" }] }), + food_sightings: (route) => route.fulfill({ json: [] }), + pings: async (route) => { + if (route.request().method() === "POST") { + // Shape of a real PostgREST/RLS rejection: non-2xx status, PostgrestError-shaped body. + return route.fulfill({ + status: 403, + json: { code: "42501", message: 'new row violates row-level security policy for table "pings"', details: null, hint: null }, + }); + } + return route.fulfill({ json: [] }); + }, + }); + + await page.goto("/notifications"); + await expect(page.getByRole("heading", { name: "Ping a friend" })).toBeVisible({ timeout: 15_000 }); + + await page.getByLabel("Friend").selectOption(FRIEND_ID); + await page.getByRole("button", { name: "Send ping" }).click(); + + await expect(page.getByRole("alert")).toBeVisible(); + await expect(page.getByText("Ping sent")).toHaveCount(0); + }); + + test("unread sighting mark-as-read is reachable by keyboard, not just onmouseenter (closes #62)", async ({ page }) => { + const requests = await signInAndMockSupabase(page, { + profiles: profilesHandler({ notifications_enabled: true }), + friendships: (route) => route.fulfill({ json: [] }), + pings: (route) => route.fulfill({ json: [] }), + food_sightings: async (route) => { + if (route.request().method() === "PATCH") return route.fulfill({ json: [] }); + return route.fulfill({ json: [{ id: "s1", dish_name: "French Toast", hall_tid: HAMPSHIRE_TID, sighted_date: "2026-08-19", read_at: null, created_at: new Date().toISOString() }] }); + }, + }); + + await page.goto("/notifications"); + const row = page.locator("li", { hasText: "French Toast" }); + await expect(row.locator(".badge", { hasText: "New" })).toBeVisible({ timeout: 15_000 }); + + // Keyboard only: focus the button (proving it's actually in the tab order, not just + // clickable) and press Enter, never a mouse event -- #62 asks for a keyboard-only walkthrough. + const markReadButton = row.getByRole("button", { name: "Mark as read" }); + await markReadButton.focus(); + await expect(markReadButton).toBeFocused(); + await markReadButton.press("Enter"); + + const patch = await waitForRequest(requests, "food_sightings", "PATCH"); + expect(patch.url.searchParams.get("id")).toBe("eq.s1"); + await expect(row.locator(".badge", { hasText: "New" })).toHaveCount(0); + }); + + test("read sightings stay visually de-emphasized and don't offer mark-as-read again", async ({ page }) => { + await signInAndMockSupabase(page, { + profiles: profilesHandler({ notifications_enabled: true }), + friendships: (route) => route.fulfill({ json: [] }), + pings: (route) => route.fulfill({ json: [] }), + food_sightings: (route) => + route.fulfill({ + json: [{ id: "s2", dish_name: "Fried Plantain", hall_tid: WORCESTER_TID, sighted_date: "2026-08-18", read_at: new Date().toISOString(), created_at: new Date().toISOString() }], + }), + }); + + await page.goto("/notifications"); + const readRow = page.locator("li", { hasText: "Fried Plantain" }); + await expect(readRow).toBeVisible({ timeout: 15_000 }); + await expect(readRow.locator(".badge", { hasText: "New" })).toHaveCount(0); + await expect(readRow.locator(".badge", { hasText: "Worcester" })).toBeVisible(); + await expect(readRow.getByRole("button", { name: "Mark as read" })).toHaveCount(0); + }); + + // Review findings on #66's PR (#75), F1 + F2: the previous `refresh().then(() => + // saveFeedLastSeen(new Date().toISOString()))` stamped the device-local unread watermark + // unconditionally -- including when refresh() early-returned with no session -- and + // feedLastSeen.ts itself had no test that would fail if isUnread()'s ping branch were hardcoded + // to `return true` or if the saveFeedLastSeen() call were deleted outright. These two tests + // close both gaps. + test("a seeded watermark suppresses the New badge on an older ping while a newer ping stays badged", async ({ page }) => { + const older = new Date(Date.now() - 60_000).toISOString(); + const watermark = new Date(Date.now() - 30_000).toISOString(); + const newer = new Date().toISOString(); + + // Seed the watermark before any app script runs, same technique as the session cookie below. + // Key format (`${prefix}:${userId}`) must match feedLastSeen.ts. + await page.addInitScript( + ([key, value]) => localStorage.setItem(key, value), + [`udine-feed-last-seen:${USER_ID}`, watermark], + ); + + await signInAndMockSupabase(page, { + profiles: profilesHandler({ notifications_enabled: true }, [{ user_id: FRIEND_ID, display_name: "Casey Friend" }]), + friendships: (route) => route.fulfill({ json: [{ user_a: USER_ID < FRIEND_ID ? USER_ID : FRIEND_ID, user_b: USER_ID < FRIEND_ID ? FRIEND_ID : USER_ID, status: "accepted" }] }), + food_sightings: (route) => route.fulfill({ json: [] }), + pings: (route) => route.fulfill({ json: [ - { id: "s1", dish_name: "French Toast", hall_tid: 3, sighted_date: "2026-08-19", read_at: null, created_at: new Date().toISOString() }, - { id: "s2", dish_name: "Fried Plantain", hall_tid: 1, sighted_date: "2026-08-18", read_at: new Date().toISOString(), created_at: new Date().toISOString() }, + { id: "p-old", sender_id: FRIEND_ID, hall_tid: null, message: "old ping", created_at: older }, + { id: "p-new", sender_id: FRIEND_ID, hall_tid: null, message: "new ping", created_at: newer }, ], }), }); await page.goto("/notifications"); - await expect(page.getByRole("heading", { name: "Sightings" })).toBeVisible({ timeout: 15_000 }); + const feed = page.getByRole("list", { name: "Activity feed" }); + const items = feed.getByRole("listitem"); + await expect(items).toHaveCount(2, { timeout: 15_000 }); // hydration proof - const unreadRow = page.locator("li", { hasText: "French Toast" }); - await expect(unreadRow.locator(".badge", { hasText: "New" })).toBeVisible(); - await expect(unreadRow.locator(".badge", { hasText: "Hampshire" })).toBeVisible(); + // Newest first (sort order, unrelated to this test's point, but asserted so nth(0)/nth(1) below + // are unambiguous). + const newItem = items.nth(0); + const oldItem = items.nth(1); + await expect(newItem).toContainText("new ping"); + await expect(oldItem).toContainText("old ping"); - const readRow = page.locator("li", { hasText: "Fried Plantain" }); - await expect(readRow.locator(".badge", { hasText: "New" })).toHaveCount(0); - await expect(readRow.locator(".badge", { hasText: "Worcester" })).toBeVisible(); + // The point: older-than-watermark ping is read, newer-than-watermark ping is unread. + await expect(newItem.locator(".badge", { hasText: "New" })).toBeVisible(); + await expect(oldItem.locator(".badge", { hasText: "New" })).toHaveCount(0); + + // Also catches the other mutation the review proved green under the old suite: deleting the + // saveFeedLastSeen() call outright. If that call were gone, the seeded watermark above would + // still sit untouched at 30s-ago instead of advancing to the newest item's own created_at. + await expect + .poll(() => page.evaluate((k) => localStorage.getItem(k), `udine-feed-last-seen:${USER_ID}`)) + .toBe(newer); + }); + + test("a signed-out visit does not stamp the feed-last-seen watermark", async ({ page }) => { + await page.goto("/notifications"); + await expect(page.locator(".empty-state")).toBeVisible(); + + // This branch renders identically whether or not onMount/hydration has run yet (nothing here + // is session-gated content that would prove hydration, unlike other specs' star-click proof), + // so give onMount's async refresh().then() a real beat to land before asserting its absence -- + // same idea as home-dashboard.spec.ts's post-hydration `waitForTimeout(500)`, just longer since + // there's no separate hydration-proof step here to already have consumed that time. + await page.waitForTimeout(1500); + + // Regression check for the finding above: refresh() early-returns with no session (nothing + // rendered), so nothing should be written to localStorage at all. + const keys = await page.evaluate(() => Object.keys(localStorage)); + expect(keys.some((k) => k.startsWith("udine-feed-last-seen"))).toBe(false); }); }); diff --git a/web/e2e/home-dashboard.spec.ts b/web/e2e/home-dashboard.spec.ts index 23061ddd..f173c96e 100644 --- a/web/e2e/home-dashboard.spec.ts +++ b/web/e2e/home-dashboard.spec.ts @@ -101,11 +101,28 @@ test("quick links reach Rank and Favorites without ambiguity against the primary await expect(page.getByRole("heading", { name: "Favorites", level: 1 })).toBeVisible(); }); -test("home issues zero API requests -- macro stats are device-local, not a server call", async ({ page }) => { - let apiRequests = 0; - await page.route("**/api/**", (route) => { - apiRequests++; - return route.continue(); +// app.css (#42) @imports Google Fonts, so every page -- including this one -- legitimately makes a +// cross-origin request for it. Not a residency leak: it's a stylesheet fetch, not data leaving the +// device. Anything else cross-origin, or any same-origin /api/ path, is what this guard exists to +// catch. +const ALLOWED_CROSS_ORIGIN_HOSTS = new Set(["fonts.googleapis.com", "fonts.gstatic.com"]); + +test("home issues zero API/cross-origin requests -- macro stats are device-local, not a server call", async ({ page, baseURL }) => { + // Carry-over from PR #69's review (see issue #66's tracker comment): a page.route("**/api/**") + // guard only ever sees requests matching that glob, so a direct cross-origin fetch to + // *.supabase.co silently passes it -- proven by mutating this page with a + // fetch(..., { mode: "no-cors" }) call and watching the old guard stay green. page.on("request") + // fires for every request the page issues, no-cors included, so nothing routes around it. + const offenders: string[] = []; + const sameOrigin = new URL(baseURL!).origin; + page.on("request", (req) => { + const url = new URL(req.url()); + if (url.protocol !== "http:" && url.protocol !== "https:") return; // data:/blob: aren't egress + if (url.origin === sameOrigin) { + if (url.pathname.includes("/api/")) offenders.push(req.url()); + return; + } + if (!ALLOWED_CROSS_ORIGIN_HOSTS.has(url.hostname)) offenders.push(req.url()); }); await page.goto("/"); @@ -113,5 +130,5 @@ test("home issues zero API requests -- macro stats are device-local, not a serve // Give any accidental fetch a beat to land before asserting its absence. await page.waitForTimeout(500); - expect(apiRequests).toBe(0); + expect(offenders, `unexpected /api/ or cross-origin request(s): ${offenders.join(", ")}`).toEqual([]); }); diff --git a/web/src/lib/feedLastSeen.ts b/web/src/lib/feedLastSeen.ts new file mode 100644 index 00000000..239e982e --- /dev/null +++ b/web/src/lib/feedLastSeen.ts @@ -0,0 +1,16 @@ +// Device-local "last viewed the activity feed" marker. `pings` has no `read_at` column (see +// CLAUDE.md's residency table and issue #66 — no schema changes), so ping unread state is derived +// from this instead of a server flag. It's UI state, not health/food data, so localStorage is fine +// per the residency doc. Same shape as preferences.ts. +// +// Keyed per user id (not a single global key) so a second account signing in on the same browser +// doesn't inherit the first account's watermark and silently mark its own unread pings as seen. +const KEY_PREFIX = "udine-feed-last-seen"; + +export function loadFeedLastSeen(userId: string): string { + return localStorage.getItem(`${KEY_PREFIX}:${userId}`) ?? new Date(0).toISOString(); +} + +export function saveFeedLastSeen(userId: string, iso: string): void { + localStorage.setItem(`${KEY_PREFIX}:${userId}`, iso); +} diff --git a/web/src/routes/friends/+page.svelte b/web/src/routes/friends/+page.svelte index 4a35348c..745a22d9 100644 --- a/web/src/routes/friends/+page.svelte +++ b/web/src/routes/friends/+page.svelte @@ -1,26 +1,17 @@

Friends

@@ -145,58 +110,26 @@ {/if} -
+

Your friends

{#if accepted.length === 0}
No friends yet — search above to add some.
{:else} -
    + +
      {#each accepted as f (f.user_a + f.user_b)} {@const myId = page.data.session.user.id} - {@const otherId = otherUserId(f, myId)} - {@const other = profilesById.get(otherId)} -
    • -
      - Friend - {other?.display_name ?? "…"} -
      - -
      -
      - - -
      -
      - - -
      - -
      -
    • - {/each} -
    - {/if} -
- -
-

Pings you've received

- {#if inbox.length === 0} -
No pings yet.
- {:else} -
    - {#each inbox as p (p.id)} -
  • - {profilesById.get(p.sender_id)?.display_name ?? "Someone"} wants to eat - {#if p.hall_tid}{hallName(p.hall_tid)}{/if} - {#if p.message}— "{p.message}"{/if} + {@const other = profilesById.get(otherUserId(f, myId))} +
  • + Friend + {other?.display_name ?? "…"}
  • {/each}
+

+ Want to say "come eat with me"? Ping friends from the Notifications feed. +

{/if}
{/if} diff --git a/web/src/routes/notifications/+page.svelte b/web/src/routes/notifications/+page.svelte index 7ac547e9..c0417227 100644 --- a/web/src/routes/notifications/+page.svelte +++ b/web/src/routes/notifications/+page.svelte @@ -4,9 +4,16 @@ import type { SupabaseClient } from "@supabase/supabase-js"; import { DINING_HALLS, syncFavoritedFoods, type Favorite } from "@udine/shared"; import { IndexedDbFavoritesStorage } from "$lib/favoritesStorage"; + import { loadFeedLastSeen, saveFeedLastSeen } from "$lib/feedLastSeen"; import { PUBLIC_VAPID_KEY } from "$env/static/public"; type Sighting = { id: string; dish_name: string; hall_tid: number; sighted_date: string; read_at: string | null; created_at: string }; + type Ping = { id: string; sender_id: string; hall_tid: number | null; message: string | null; created_at: string }; + type Profile = { user_id: string; display_name: string }; + + type FeedItem = + | { kind: "sighting"; id: string; createdAt: string; dishName: string; hallTid: number; sightedDate: string; readAt: string | null } + | { kind: "ping"; id: string; createdAt: string; senderName: string; hallTid: number | null; message: string | null }; // Standard urlBase64-to-Uint8Array conversion PushManager.subscribe needs for applicationServerKey. function urlBase64ToUint8Array(base64: string): Uint8Array { @@ -63,31 +70,108 @@ let notificationsEnabled = $state(false); let sightings: Sighting[] = $state([]); + let pings: Ping[] = $state([]); + // Accepted friends only — enough to populate the "ping a friend" composer and to resolve a + // ping's sender name. Friend search/request/accept itself stays on /friends (see #66). + let friends: Profile[] = $state([]); + // Snapshot of "last time this feed was viewed", read once at mount before it's overwritten below + // -- deliberately a plain variable, not $state, so a ping's unread badge doesn't flip off mid-visit. + let feedLastSeenAt = ""; + + let selectedFriendId = $state(""); + let pingHallTid = $state(""); + let pingMessage = $state(""); + let pingSent = $state(false); + let pingError = $state(false); + + function hallName(hallTid: number | null): string { + return DINING_HALLS.find((h) => h.tid === hallTid)?.name ?? "somewhere"; + } + + let friendNameById = $derived(new Map(friends.map((f) => [f.user_id, f.display_name]))); - function hallName(hallTid: number): string { - return DINING_HALLS.find((h) => h.tid === hallTid)?.name ?? `Hall ${hallTid}`; + let feedItems: FeedItem[] = $derived( + [ + ...sightings.map( + (s): FeedItem => ({ kind: "sighting", id: s.id, createdAt: s.created_at, dishName: s.dish_name, hallTid: s.hall_tid, sightedDate: s.sighted_date, readAt: s.read_at }), + ), + ...pings.map( + (p): FeedItem => ({ kind: "ping", id: p.id, createdAt: p.created_at, senderName: friendNameById.get(p.sender_id) ?? "A friend", hallTid: p.hall_tid, message: p.message }), + ), + // b.localeCompare(a) for descending (newest-first) order. The previous `a < b ? 1 : -1` + // returned -1 for BOTH orderings of two equal-timestamp items (cmp(a,b) and cmp(b,a) both + // said "I go first"), an invalid comparator that Array.sort doesn't guarantee stable/correct + // results for. + ].sort((a, b) => b.createdAt.localeCompare(a.createdAt)), + ); + + function isUnread(item: FeedItem): boolean { + if (item.kind === "sighting") return item.readAt === null; + return item.createdAt > feedLastSeenAt; } async function refresh() { const supabase = page.data.supabase; const session = page.data.session; if (!supabase || !session) return; + const myId = session.user.id; - const { data: profile } = await supabase.from("profiles").select("notifications_enabled").eq("user_id", session.user.id).single(); + const { data: profile } = await supabase.from("profiles").select("notifications_enabled").eq("user_id", myId).single(); notificationsEnabled = profile?.notifications_enabled ?? false; // Browser permission can be revoked outside this page (browser settings) without us hearing // about it — if that happened, the stored push_tokens row is now dead, so clear it. The // in-app notifications_enabled flag (and food_sightings feed) is untouched either way. if (notificationsEnabled && pushSupported() && Notification.permission !== "granted") { - await clearStoredPushTokens(supabase, session.user.id); + await clearStoredPushTokens(supabase, myId); + } + + const { data: sightingRows } = await supabase.from("food_sightings").select("*").eq("user_id", myId).order("created_at", { ascending: false }); + sightings = sightingRows ?? []; + + // Friends resolved *before* pings assigns below -- friendNameById needs to already be + // populated when a ping first renders, or a sender briefly shows as the "A friend" fallback + // until this catches up. + const { data: fs } = await supabase.from("friendships").select("*").or(`user_a.eq.${myId},user_b.eq.${myId}`).eq("status", "accepted"); + const otherIds = (fs ?? []).map((f: { user_a: string; user_b: string }) => (f.user_a === myId ? f.user_b : f.user_a)); + if (otherIds.length > 0) { + const { data: profs } = await supabase.from("profiles").select("user_id, display_name").in("user_id", otherIds); + friends = profs ?? []; + } else { + friends = []; } - const { data } = await supabase.from("food_sightings").select("*").eq("user_id", session.user.id).order("created_at", { ascending: false }); - sightings = data ?? []; + const { data: pingRows } = await supabase.from("pings").select("*").eq("receiver_id", myId).order("created_at", { ascending: false }); + pings = pingRows ?? []; } - onMount(refresh); + onMount(() => { + const myId = page.data.session?.user.id; + feedLastSeenAt = myId ? loadFeedLastSeen(myId) : ""; + refresh().then(() => { + // Only stamp when a session actually exists -- refresh() early-returns with no session + // (nothing rendered), and previously this ran unconditionally, silently marking every + // older ping as seen on a zero-item anonymous visit. Stamp from the newest rendered item's + // own created_at (Postgres clock), not the browser's clock -- avoids drift between the two + // making a just-created ping look already-seen. + const newestCreatedAt = feedItems[0]?.createdAt; + if (myId && newestCreatedAt) saveFeedLastSeen(myId, newestCreatedAt); + }); + + const supabase = page.data.supabase; + if (!supabase || !myId) return; + + // Moved here from /friends (#66) — the pings inbox now lives in this feed, not buried in the + // friends page's accepted-friends list. + const channel = supabase + .channel("pings-inbox") + .on("postgres_changes", { event: "INSERT", schema: "public", table: "pings", filter: `receiver_id=eq.${myId}` }, refresh) + .subscribe(); + + return () => { + supabase.removeChannel(channel); + }; + }); async function toggleNotifications() { const supabase = page.data.supabase; @@ -110,13 +194,38 @@ } } - async function markRead(sighting: Sighting) { + async function markRead(sightingId: string) { const supabase = page.data.supabase; - if (!supabase || sighting.read_at) return; + const sighting = sightings.find((s) => s.id === sightingId); + if (!supabase || !sighting || sighting.read_at) return; const readAt = new Date().toISOString(); - await supabase.from("food_sightings").update({ read_at: readAt }).eq("id", sighting.id); + await supabase.from("food_sightings").update({ read_at: readAt }).eq("id", sightingId); sighting.read_at = readAt; } + + async function sendPing() { + const supabase = page.data.supabase; + const myId = page.data.session?.user.id; + if (!supabase || !myId || !selectedFriendId) return; + const { error } = await supabase.from("pings").insert({ + sender_id: myId, + receiver_id: selectedFriendId, + hall_tid: pingHallTid ? Number(pingHallTid) : null, + message: pingMessage || null, + }); + if (error) { + // e.g. RLS rejects the insert (not actually friends, receiver blocked). Previously this was + // never checked -- "Ping sent" showed unconditionally even when nothing was sent. + pingSent = false; // in case a still-live success badge from an earlier attempt is showing + pingError = true; + setTimeout(() => (pingError = false), 3000); + return; + } + pingMessage = ""; + pingError = false; // in case a still-live failure badge from an earlier attempt is showing + pingSent = true; + setTimeout(() => (pingSent = false), 1500); + }

Notifications

@@ -124,12 +233,13 @@ {#if !page.data.session}
-

Sign in to enable favorited-food alerts.

+

Sign in to see this feed — pings from friends and favorited-dish alerts, all in one place.

+

Use "Sign in with Google" above to get started.

{:else} -
+

Notify me when a favorited dish shows up on the menu.

@@ -139,25 +249,70 @@
-

- Turning this on will ask your browser for notification permission and register a push subscription. This page is always the notification feed either way. -

-

Sightings

- {#if sightings.length === 0} -
No favorited-food sightings yet.
+ +
+

Ping a friend

+ {#if friends.length === 0} +

Add friends on the Friends page to send pings.

+ {:else} +
+
+ + +
+
+ + +
+
+ + +
+ +
+ {#if pingSent}

Ping sent

{/if} + {#if pingError}{/if} + {/if} +
+ +

Activity

+ {#if feedItems.length === 0} +
Nothing here yet — pings from friends and favorited-dish sightings will show up here.
{:else} -
    - {#each sightings as s (s.id)} - -
  • markRead(s)}> - {#if !s.read_at}New{/if} - {s.dish_name} - {hallName(s.hall_tid)} - on {s.sighted_date} +
      + {#each feedItems as item (item.kind + item.id)} + {@const unread = isUnread(item)} + +
    • markRead(item.id) : undefined}> + {#if unread}New{/if} + {#if item.kind === "sighting"} + {item.dishName} + {hallName(item.hallTid)} + spotted on {item.sightedDate} + {#if unread} + + {/if} + {:else} + Ping + {item.senderName} wants to eat + {#if item.hallTid}{hallName(item.hallTid)}{/if} + {#if item.message}— "{item.message}"{/if} + {/if}
    • {/each}