diff --git a/.env.example b/.env.example index 923756d4..d6c2a39a 100644 --- a/.env.example +++ b/.env.example @@ -30,3 +30,7 @@ NEXT_PUBLIC_POSTHOG_KEY= # Dev (optional) PORTLESS_URL= + +# Perf harness test user (see docs/perf/PLAN.md) +PERF_TEST_EMAIL= +PERF_TEST_PASSWORD= diff --git a/.gitignore b/.gitignore index 984ed5ba..2c74792a 100644 --- a/.gitignore +++ b/.gitignore @@ -91,3 +91,7 @@ AGENTS.md .waypoint/docs/code-guide.md .env .env.local + +# perf harness output & auth state +perf-results/ +apps/web/e2e/perf/.auth/ diff --git a/apps/web/__tests__/api/contributions.test.ts b/apps/web/__tests__/api/contributions.test.ts index 525dcc9f..42f1511f 100644 --- a/apps/web/__tests__/api/contributions.test.ts +++ b/apps/web/__tests__/api/contributions.test.ts @@ -40,6 +40,7 @@ describe("GET /api/users/[username]/contributions", () => { const client: Record = { auth: { + getClaims: vi.fn().mockResolvedValue({ data: null, error: null }), getUser: vi.fn().mockResolvedValue({ data: { user: null }, error: null, @@ -113,6 +114,7 @@ describe("GET /api/users/[username]/contributions", () => { it("returns 404 for non-existent user", async () => { const client: Record = { auth: { + getClaims: vi.fn().mockResolvedValue({ data: null, error: null }), getUser: vi.fn().mockResolvedValue({ data: { user: null }, error: null, @@ -151,6 +153,7 @@ describe("GET /api/users/[username]/contributions", () => { it("returns empty data when user has no usage", async () => { const client: Record = { auth: { + getClaims: vi.fn().mockResolvedValue({ data: null, error: null }), getUser: vi.fn().mockResolvedValue({ data: { user: null }, error: null, diff --git a/apps/web/__tests__/api/leaderboard.test.ts b/apps/web/__tests__/api/leaderboard.test.ts index 44bb1bfc..c9f43ff4 100644 --- a/apps/web/__tests__/api/leaderboard.test.ts +++ b/apps/web/__tests__/api/leaderboard.test.ts @@ -1,5 +1,21 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; +const leaderboardMocks = vi.hoisted(() => ({ + loadEntries: vi.fn(), + loadRank: vi.fn(), + getAuthIdentity: vi.fn(), +})); + +vi.mock("@/lib/supabase/auth", () => ({ + getAuthIdentity: leaderboardMocks.getAuthIdentity, +})); + +vi.mock("@/lib/data/leaderboard", () => ({ + LEADERBOARD_PERIODS: ["day", "week", "month", "all_time"], + loadLeaderboardEntries: leaderboardMocks.loadEntries, + loadLeaderboardRank: leaderboardMocks.loadRank, +})); + vi.mock("@/lib/supabase/server", () => ({ createClient: vi.fn(), })); @@ -106,6 +122,9 @@ function mockSupabase(opts: { beforeEach(() => { vi.clearAllMocks(); + leaderboardMocks.loadEntries.mockResolvedValue([]); + leaderboardMocks.loadRank.mockResolvedValue(null); + leaderboardMocks.getAuthIdentity.mockResolvedValue(null); }); describe("GET /api/leaderboard", () => { @@ -114,6 +133,7 @@ describe("GET /api/leaderboard", () => { { user_id: "u1", total_cost: 100, username: "alice" }, { user_id: "u2", total_cost: 50, username: "bob" }, ]; + leaderboardMocks.loadEntries.mockResolvedValue(entries); // Use a simple mock where the main query returns entries const client: Record = { @@ -175,8 +195,12 @@ describe("GET /api/leaderboard", () => { await GET(makeRequest()); - // The from() call should use leaderboard_weekly view - expect(client.from).toHaveBeenCalledWith("leaderboard_weekly"); + expect(leaderboardMocks.loadEntries).toHaveBeenCalledWith({ + period: "week", + region: null, + cursor: null, + limit: 50, + }); }); it("filters by period", async () => { @@ -200,7 +224,12 @@ describe("GET /api/leaderboard", () => { (getServiceClient as any).mockReturnValue(client); await GET(makeRequest({ period: "month" })); - expect(client.from).toHaveBeenCalledWith("leaderboard_monthly"); + expect(leaderboardMocks.loadEntries).toHaveBeenCalledWith({ + period: "month", + region: null, + cursor: null, + limit: 50, + }); }); it("rejects invalid period", async () => { @@ -256,6 +285,12 @@ describe("GET /api/leaderboard", () => { const json = await res.json(); expect(res.status).toBe(200); + expect(leaderboardMocks.loadEntries).toHaveBeenCalledWith({ + period: "week", + region: "north_america", + cursor: null, + limit: 50, + }); }); it("includes user_rank for current user in page", async () => { @@ -263,6 +298,11 @@ describe("GET /api/leaderboard", () => { { user_id: "u1", total_cost: 100 }, { user_id: "current-user", total_cost: 50 }, ]; + leaderboardMocks.loadEntries.mockResolvedValue(entries); + leaderboardMocks.getAuthIdentity.mockResolvedValue({ + id: "current-user", + email: null, + }); const client: Record = { auth: { @@ -298,6 +338,7 @@ describe("GET /api/leaderboard", () => { user_id: `u${i}`, total_cost: 100 - i, })); + leaderboardMocks.loadEntries.mockResolvedValue(entries); const client: Record = { auth: { diff --git a/apps/web/__tests__/api/messages.test.ts b/apps/web/__tests__/api/messages.test.ts index 4e1e170d..94dfb1c3 100644 --- a/apps/web/__tests__/api/messages.test.ts +++ b/apps/web/__tests__/api/messages.test.ts @@ -510,8 +510,12 @@ describe("message attachments", () => { }), storage: { from: vi.fn().mockReturnValue({ - createSignedUrl: vi.fn().mockResolvedValue({ - data: { signedUrl: "https://example.supabase.co/storage/v1/object/sign/dm-attachments/user-2/file.png?token=abc" }, + createSignedUrls: vi.fn().mockResolvedValue({ + data: [{ + path: "user-2/file.png", + signedUrl: "https://example.supabase.co/storage/v1/object/sign/dm-attachments/user-2/file.png?token=abc", + error: null, + }], error: null, }), }), @@ -539,8 +543,12 @@ describe("message attachments", () => { }), }, }; - const createSignedUrl = vi.fn().mockResolvedValue({ - data: { signedUrl: "https://example.supabase.co/storage/v1/object/sign/dm-attachments/user-2/file.png?token=abc" }, + const createSignedUrls = vi.fn().mockResolvedValue({ + data: [{ + path: "user-2/file.png", + signedUrl: "https://example.supabase.co/storage/v1/object/sign/dm-attachments/user-2/file.png?token=abc", + error: null, + }], error: null, }); const serviceClient: Record = { @@ -619,7 +627,7 @@ describe("message attachments", () => { }), storage: { from: vi.fn().mockReturnValue({ - createSignedUrl, + createSignedUrls, }), }, }; @@ -634,7 +642,7 @@ describe("message attachments", () => { expect(response.status).toBe(200); expect(json.messages[0].attachments).toEqual([]); - expect(createSignedUrl).not.toHaveBeenCalled(); + expect(createSignedUrls).not.toHaveBeenCalled(); }); }); diff --git a/apps/web/__tests__/api/profile.test.ts b/apps/web/__tests__/api/profile.test.ts index c4a3f6f5..3c6146d2 100644 --- a/apps/web/__tests__/api/profile.test.ts +++ b/apps/web/__tests__/api/profile.test.ts @@ -64,6 +64,10 @@ describe("GET /api/users/[username]", () => { const client: Record = { auth: { + getClaims: vi.fn().mockResolvedValue({ + data: { claims: { sub: "viewer-1" } }, + error: null, + }), getUser: vi.fn().mockResolvedValue({ data: { user: { id: "viewer-1" } }, error: null, @@ -192,6 +196,7 @@ describe("GET /api/users/[username]", () => { it("returns 404 for non-existent username", async () => { const client: Record = { auth: { + getClaims: vi.fn().mockResolvedValue({ data: null, error: null }), getUser: vi.fn().mockResolvedValue({ data: { user: null }, error: null, diff --git a/apps/web/__tests__/api/right-sidebar.test.ts b/apps/web/__tests__/api/right-sidebar.test.ts new file mode 100644 index 00000000..4fabfe37 --- /dev/null +++ b/apps/web/__tests__/api/right-sidebar.test.ts @@ -0,0 +1,110 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + createClient: vi.fn(), + loadUsageTotals: vi.fn(), + loadPublicData: vi.fn(), + getAuthIdentity: vi.fn(), +})); + +vi.mock("@/lib/supabase/server", () => ({ + createClient: mocks.createClient, +})); + +vi.mock("@/lib/supabase/auth", () => ({ + getAuthIdentity: mocks.getAuthIdentity, +})); + +vi.mock("@/lib/data/usage-totals", () => ({ + loadUsageTotals: mocks.loadUsageTotals, +})); + +vi.mock("@/lib/data/right-sidebar", () => ({ + loadRightSidebarPublicData: mocks.loadPublicData, +})); + +import { GET } from "@/app/api/app/right-sidebar/route"; + +function clientFor(userId: string, followingIds: string[]) { + return { + auth: { + getUser: vi.fn().mockResolvedValue({ + data: { user: { id: userId } }, + error: null, + }), + }, + from: vi.fn(() => ({ + select: vi.fn(() => ({ + eq: vi.fn().mockResolvedValue({ + data: followingIds.map((following_id) => ({ following_id })), + error: null, + }), + })), + })), + }; +} + +describe("GET /api/app/right-sidebar", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.loadUsageTotals.mockImplementation(async (_client: unknown, userId: string) => ({ + totalTokens: userId === "viewer-1" ? 100 : 200, + totalCost: 0, + })); + mocks.loadPublicData.mockResolvedValue({ + activeUsers: [ + { id: "a", username: "a", avatar_url: null, bio: null }, + { id: "b", username: "b", avatar_url: null, bio: null }, + { id: "c", username: "c", avatar_url: null, bio: null }, + ], + newSignups: [], + pinnedUsers: [], + topUsers: [], + }); + }); + + it("keeps follows and usage totals request-scoped across users", async () => { + mocks.createClient + .mockResolvedValueOnce(clientFor("viewer-1", ["a"])) + .mockResolvedValueOnce(clientFor("viewer-2", ["b"])); + mocks.getAuthIdentity + .mockResolvedValueOnce({ id: "viewer-1", email: null }) + .mockResolvedValueOnce({ id: "viewer-2", email: null }); + + const first = await GET(); + const second = await GET(); + const firstBody = await first.json(); + const secondBody = await second.json(); + + expect(firstBody.suggested.map((user: { id: string }) => user.id)).toEqual([ + "b", + "c", + ]); + expect(secondBody.suggested.map((user: { id: string }) => user.id)).toEqual([ + "a", + "c", + ]); + expect(firstBody.totalOutputTokens).toBe(100); + expect(secondBody.totalOutputTokens).toBe(200); + expect(mocks.loadPublicData).toHaveBeenCalledTimes(2); + expect(mocks.loadUsageTotals.mock.calls.map((call) => call[1])).toEqual([ + "viewer-1", + "viewer-2", + ]); + }); + + it("only admits public pinned users into the shared candidate cache", () => { + const source = readFileSync( + join(process.cwd(), "lib/data/right-sidebar.ts"), + "utf8" + ); + + expect(source).toMatch( + /from\("users"\)[\s\S]*?\.eq\("is_public", true\)[\s\S]*?\.eq\("is_pinned_suggestion", true\)/ + ); + expect(source).not.toContain("follower_id"); + expect(source).not.toContain("loadUsageTotals"); + }); +}); diff --git a/apps/web/__tests__/api/usage-submit.test.ts b/apps/web/__tests__/api/usage-submit.test.ts index 43144432..8d5e85d3 100644 --- a/apps/web/__tests__/api/usage-submit.test.ts +++ b/apps/web/__tests__/api/usage-submit.test.ts @@ -21,7 +21,8 @@ vi.mock("@supabase/supabase-js", () => ({ createClient: vi.fn(), })); -import { POST, aggregateDeviceRows } from "@/app/api/usage/submit/route"; +import { POST } from "@/app/api/usage/submit/route"; +import { aggregateDeviceRows } from "@/lib/usage/aggregate-device-rows"; import { captureServerActivationEvent } from "@/lib/analytics/server"; import { createClient } from "@/lib/supabase/server"; import { verifyCliToken, verifyCliTokenWithRefresh } from "@/lib/api/cli-auth"; diff --git a/apps/web/__tests__/components/PostHogProvider.test.tsx b/apps/web/__tests__/components/PostHogProvider.test.tsx new file mode 100644 index 00000000..feba0ffd --- /dev/null +++ b/apps/web/__tests__/components/PostHogProvider.test.tsx @@ -0,0 +1,152 @@ +import { act, render, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { useReportWebVitals } from "next/web-vitals"; +import { + PostHogClientProvider, + WebVitalsReporter, +} from "@/components/providers/PostHogProvider"; + +const mocks = vi.hoisted(() => { + process.env.NEXT_PUBLIC_POSTHOG_KEY = "ph_test"; + + return { + capture: vi.fn(), + init: vi.fn(), + analyticsConsent: false, + unsubscribe: vi.fn(), + reportWebVitals: undefined as + | Parameters[0] + | undefined, + }; +}); + +vi.mock("next/web-vitals", () => ({ + useReportWebVitals: (reporter: Parameters[0]) => { + mocks.reportWebVitals = reporter; + }, +})); + +vi.mock("posthog-js", () => ({ + default: { + capture: mocks.capture, + identify: vi.fn(), + init: mocks.init, + reset: vi.fn(), + }, +})); + +vi.mock("posthog-js/react", () => ({ + PostHogProvider: ({ children }: { children: React.ReactNode }) => children, +})); + +vi.mock("@/components/providers/useAnalyticsConsent", () => ({ + useAnalyticsConsent: () => mocks.analyticsConsent, +})); + +vi.mock("@/lib/supabase/client", () => ({ + createClient: () => ({ + auth: { + onAuthStateChange: () => ({ + data: { subscription: { unsubscribe: mocks.unsubscribe } }, + }), + }, + }), +})); + +describe("WebVitalsReporter", () => { + beforeEach(() => { + mocks.capture.mockClear(); + mocks.init.mockClear(); + mocks.analyticsConsent = false; + mocks.reportWebVitals = undefined; + mocks.unsubscribe.mockClear(); + window.__straudePostHogInitialized = undefined; + window.history.replaceState({}, "", "/feed?sort=recent"); + }); + + it("enables built-in web vitals only after analytics consent", async () => { + const { rerender } = render( + +
Content
+
, + ); + + expect(mocks.init).not.toHaveBeenCalled(); + + mocks.analyticsConsent = true; + rerender( + +
Content
+
, + ); + + await waitFor(() => expect(mocks.init).toHaveBeenCalledOnce()); + expect(mocks.init).toHaveBeenCalledWith( + "ph_test", + expect.objectContaining({ + capture_performance: { web_vitals: true }, + }), + ); + }); + + it("buffers TTFB until PostHog is ready and ignores built-in metrics", async () => { + const { rerender } = render(); + + act(() => { + mocks.reportWebVitals?.({ + name: "LCP", + id: "lcp-1", + value: 450, + delta: 450, + rating: "good", + entries: [], + navigationType: "navigate", + }); + mocks.reportWebVitals?.({ + name: "TTFB", + id: "ttfb-1", + value: 180, + delta: 180, + rating: "good", + entries: [], + navigationType: "navigate", + }); + }); + + expect(mocks.capture).not.toHaveBeenCalled(); + + rerender(); + + await waitFor(() => { + expect(mocks.capture).toHaveBeenCalledOnce(); + }); + expect(mocks.capture).toHaveBeenCalledWith("web_vital_ttfb", { + metric_name: "TTFB", + value_ms: 180, + metric_id: "ttfb-1", + rating: "good", + navigation_type: "navigate", + pathname: "/feed", + $current_url: "http://localhost:3000/feed?sort=recent", + }); + }); + + it("does not report the same TTFB metric twice", async () => { + render(); + const metric = { + name: "TTFB" as const, + id: "ttfb-1", + value: 180, + delta: 180, + rating: "good" as const, + entries: [], + navigationType: "navigate" as const, + }; + + act(() => mocks.reportWebVitals?.(metric)); + await waitFor(() => expect(mocks.capture).toHaveBeenCalledOnce()); + act(() => mocks.reportWebVitals?.(metric)); + + expect(mocks.capture).toHaveBeenCalledOnce(); + }); +}); diff --git a/apps/web/__tests__/flows/leaderboard-ranking.test.ts b/apps/web/__tests__/flows/leaderboard-ranking.test.ts index 6f9c918b..3196af98 100644 --- a/apps/web/__tests__/flows/leaderboard-ranking.test.ts +++ b/apps/web/__tests__/flows/leaderboard-ranking.test.ts @@ -1,5 +1,21 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; +const leaderboardMocks = vi.hoisted(() => ({ + loadEntries: vi.fn(), + loadRank: vi.fn(), + getAuthIdentity: vi.fn(), +})); + +vi.mock("@/lib/supabase/auth", () => ({ + getAuthIdentity: leaderboardMocks.getAuthIdentity, +})); + +vi.mock("@/lib/data/leaderboard", () => ({ + LEADERBOARD_PERIODS: ["day", "week", "month", "all_time"], + loadLeaderboardEntries: leaderboardMocks.loadEntries, + loadLeaderboardRank: leaderboardMocks.loadRank, +})); + // --------------------------------------------------------------------------- // Mock Supabase // --------------------------------------------------------------------------- @@ -67,6 +83,9 @@ describe("Flow: Leaderboard Ranking", () => { vi.resetModules(); mockSupabase.rpc.mockResolvedValue({ data: [] }); mockServiceClient.from.mockReset(); + leaderboardMocks.loadEntries.mockResolvedValue([]); + leaderboardMocks.loadRank.mockResolvedValue(null); + leaderboardMocks.getAuthIdentity.mockResolvedValue(null); }); it("returns users sorted by cost DESC with correct rank badges", async () => { @@ -74,6 +93,10 @@ describe("Flow: Leaderboard Ranking", () => { mockSupabase.auth.getUser.mockResolvedValue({ data: { user: { id: currentUserId } }, }); + leaderboardMocks.getAuthIdentity.mockResolvedValue({ + id: currentUserId, + email: null, + }); const entries = [ { user_id: "user-1", username: "topspender", total_cost: 100.0, region: "north_america" }, @@ -81,6 +104,7 @@ describe("Flow: Leaderboard Ranking", () => { { user_id: "user-3", username: "lowspender", total_cost: 25.0, region: "asia" }, { user_id: "user-4", username: "casual", total_cost: 10.0, region: "north_america" }, ]; + leaderboardMocks.loadEntries.mockResolvedValue(entries); const lbChain = chainBuilder({ data: entries, error: null }); @@ -114,6 +138,7 @@ describe("Flow: Leaderboard Ranking", () => { { user_id: "user-1", username: "topspender", total_cost: 100.0, region: "north_america" }, { user_id: "user-4", username: "casual", total_cost: 10.0, region: "north_america" }, ]; + leaderboardMocks.loadEntries.mockResolvedValue(naEntries); const lbChain = chainBuilder({ data: naEntries, error: null }); @@ -130,7 +155,12 @@ describe("Flow: Leaderboard Ranking", () => { expect(res.status).toBe(200); expect(data.entries).toHaveLength(2); expect(data.entries.every((entry: { region: string }) => entry.region === "north_america")).toBe(true); - expect(lbChain.eq).toHaveBeenCalledWith("region", "north_america"); + expect(leaderboardMocks.loadEntries).toHaveBeenCalledWith({ + period: "week", + region: "north_america", + cursor: null, + limit: 50, + }); }); it("filters by period: uses correct view", async () => { @@ -146,19 +176,17 @@ describe("Flow: Leaderboard Ranking", () => { const { GET } = await import("@/app/api/leaderboard/route"); - for (const [period, view] of [ - ["day", "leaderboard_daily"], - ["week", "leaderboard_weekly"], - ["month", "leaderboard_monthly"], - ["all_time", "leaderboard_all_time"], - ] as const) { - mockSupabase.from.mockClear(); - mockSupabase.from.mockImplementation(() => lbChain); - + for (const period of ["day", "week", "month", "all_time"] as const) { + leaderboardMocks.loadEntries.mockClear(); const req = makeRequest(`http://localhost:3000/api/leaderboard?period=${period}`); await GET(req); - expect(mockSupabase.from).toHaveBeenCalledWith(view); + expect(leaderboardMocks.loadEntries).toHaveBeenCalledWith({ + period, + region: null, + cursor: null, + limit: 50, + }); } }); @@ -179,31 +207,20 @@ describe("Flow: Leaderboard Ranking", () => { mockSupabase.auth.getUser.mockResolvedValue({ data: { user: { id: currentUserId } }, }); + leaderboardMocks.getAuthIdentity.mockResolvedValue({ + id: currentUserId, + email: null, + }); // Main leaderboard does not include current user const topEntries = [ { user_id: "user-1", username: "top1", total_cost: 200.0 }, { user_id: "user-2", username: "top2", total_cost: 150.0 }, ]; + leaderboardMocks.loadEntries.mockResolvedValue(topEntries); + leaderboardMocks.loadRank.mockResolvedValue(11); - // User entry lookup - const userEntryResult = { data: { total_cost: 5.0 }, error: null }; - // Count of users above - const countResult = { count: 10, data: null, error: null }; - - let callCount = 0; - mockSupabase.from.mockImplementation(() => { - callCount++; - if (callCount === 1) return chainBuilder({ data: topEntries, error: null }); - if (callCount === 2) { - // user's entry - const c = chainBuilder(); - c.maybeSingle = vi.fn(() => Promise.resolve(userEntryResult)); - return c; - } - // count above - return chainBuilder(countResult); - }); + mockSupabase.from.mockImplementation(() => chainBuilder({ data: [], error: null })); mockServiceClient.from.mockImplementation(() => chainBuilder({ data: [], error: null }) ); @@ -215,5 +232,10 @@ describe("Flow: Leaderboard Ranking", () => { expect(res.status).toBe(200); expect(data.user_rank).toBe(11); + expect(leaderboardMocks.loadRank).toHaveBeenCalledWith( + "week", + currentUserId, + null + ); }); }); diff --git a/apps/web/__tests__/flows/privacy-visibility.test.ts b/apps/web/__tests__/flows/privacy-visibility.test.ts index aa4d951d..7de7cac6 100644 --- a/apps/web/__tests__/flows/privacy-visibility.test.ts +++ b/apps/web/__tests__/flows/privacy-visibility.test.ts @@ -1,10 +1,21 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; +const leaderboardMocks = vi.hoisted(() => ({ + loadEntries: vi.fn(), + loadRank: vi.fn(), +})); + +vi.mock("@/lib/data/leaderboard", () => ({ + LEADERBOARD_PERIODS: ["day", "week", "month", "all_time"], + loadLeaderboardEntries: leaderboardMocks.loadEntries, + loadLeaderboardRank: leaderboardMocks.loadRank, +})); + // --------------------------------------------------------------------------- // Mock Supabase // --------------------------------------------------------------------------- const mockSupabase = { - auth: { getUser: vi.fn() }, + auth: { getUser: vi.fn(), getClaims: vi.fn() }, from: vi.fn(), rpc: vi.fn(), }; @@ -74,12 +85,22 @@ describe("Flow: Privacy and Visibility", () => { mockServiceClient.rpc.mockReset(); mockSupabase.rpc.mockReset(); mockSupabase.from.mockReset(); + mockSupabase.auth.getClaims.mockImplementation(async () => { + const result = await mockSupabase.auth.getUser(); + const subject = result?.data?.user?.id; + return { + data: typeof subject === "string" ? { claims: { sub: subject } } : null, + error: result?.error ?? null, + }; + }); // Default: return array for calculate_streaks_batch (leaderboard), number for calculate_user_streak (profile) mockSupabase.rpc.mockImplementation((_fn: string) => { if (_fn === "calculate_streaks_batch") return Promise.resolve({ data: [] }); return Promise.resolve({ data: 0 }); }); mockServiceClient.rpc.mockResolvedValue({ data: 0 }); + leaderboardMocks.loadEntries.mockResolvedValue([]); + leaderboardMocks.loadRank.mockResolvedValue(null); }); it("public user appears in leaderboard", async () => { @@ -90,6 +111,7 @@ describe("Flow: Privacy and Visibility", () => { const entries = [ { user_id: PUBLIC_USER.id, username: PUBLIC_USER.username, total_cost: 50.0, region: "north_america" }, ]; + leaderboardMocks.loadEntries.mockResolvedValue(entries); const lbChain = chainBuilder(); (lbChain.select as ReturnType).mockReturnValue(lbChain); diff --git a/apps/web/__tests__/flows/profile-and-contributions.test.ts b/apps/web/__tests__/flows/profile-and-contributions.test.ts index 66d1466a..5349669b 100644 --- a/apps/web/__tests__/flows/profile-and-contributions.test.ts +++ b/apps/web/__tests__/flows/profile-and-contributions.test.ts @@ -4,7 +4,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; // Mock Supabase // --------------------------------------------------------------------------- const mockSupabase = { - auth: { getUser: vi.fn() }, + auth: { getUser: vi.fn(), getClaims: vi.fn() }, from: vi.fn(), rpc: vi.fn(), }; @@ -56,6 +56,14 @@ describe("Flow: Profile and Contributions", () => { vi.clearAllMocks(); mockServiceClient.from.mockReset(); mockServiceClient.rpc.mockReset(); + mockSupabase.auth.getClaims.mockImplementation(async () => { + const result = await mockSupabase.auth.getUser(); + const subject = result?.data?.user?.id; + return { + data: typeof subject === "string" ? { claims: { sub: subject } } : null, + error: result?.error ?? null, + }; + }); }); it("sets profile via PATCH /api/users/me", async () => { diff --git a/apps/web/__tests__/flows/signup-to-feed.test.ts b/apps/web/__tests__/flows/signup-to-feed.test.ts index 146ea3f2..97ffb4b9 100644 --- a/apps/web/__tests__/flows/signup-to-feed.test.ts +++ b/apps/web/__tests__/flows/signup-to-feed.test.ts @@ -1,10 +1,21 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; +const leaderboardMocks = vi.hoisted(() => ({ + loadEntries: vi.fn(), + loadRank: vi.fn(), +})); + +vi.mock("@/lib/data/leaderboard", () => ({ + LEADERBOARD_PERIODS: ["day", "week", "month", "all_time"], + loadLeaderboardEntries: leaderboardMocks.loadEntries, + loadLeaderboardRank: leaderboardMocks.loadRank, +})); + // --------------------------------------------------------------------------- // Mock Supabase // --------------------------------------------------------------------------- const mockSupabase = { - auth: { getUser: vi.fn() }, + auth: { getUser: vi.fn(), getClaims: vi.fn() }, from: vi.fn(), rpc: vi.fn().mockResolvedValue({ data: [] }), }; @@ -74,6 +85,16 @@ describe("Flow: Signup to Feed", () => { vi.clearAllMocks(); vi.stubEnv("NEXT_PUBLIC_APP_URL", "https://straude.com"); mockSupabase.rpc.mockResolvedValue({ data: [] }); + mockSupabase.auth.getClaims.mockImplementation(async () => { + const result = await mockSupabase.auth.getUser(); + const subject = result?.data?.user?.id; + return { + data: typeof subject === "string" ? { claims: { sub: subject } } : null, + error: result?.error ?? null, + }; + }); + leaderboardMocks.loadEntries.mockResolvedValue([]); + leaderboardMocks.loadRank.mockResolvedValue(null); }); it("new user lands on feed and sees empty results before following anyone", async () => { @@ -163,6 +184,7 @@ describe("Flow: Signup to Feed", () => { { user_id: userId, username: "alice_dev", total_cost: 12.5, region: "north_america" }, { user_id: "user-2", username: "bob", total_cost: 8.0, region: "europe" }, ]; + leaderboardMocks.loadEntries.mockResolvedValue(leaderboardEntries); const lbChain = chainBuilder(); (lbChain.select as ReturnType).mockReturnValue(lbChain); diff --git a/apps/web/__tests__/lib/leaderboard-data.test.ts b/apps/web/__tests__/lib/leaderboard-data.test.ts new file mode 100644 index 00000000..2bcf64dd --- /dev/null +++ b/apps/web/__tests__/lib/leaderboard-data.test.ts @@ -0,0 +1,79 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + from: vi.fn(), +})); + +vi.mock("next/cache", () => ({ + unstable_cache: unknown>(fn: T) => fn, +})); + +vi.mock("@/lib/supabase/service", () => ({ + getServiceClient: vi.fn(() => ({ from: mocks.from })), +})); + +import { loadLeaderboardEntries } from "@/lib/data/leaderboard"; + +function queryResult(result: Record) { + const chain: Record> & { + then?: ( + resolve: (value: Record) => unknown, + reject?: (error: unknown) => unknown + ) => Promise; + } = {}; + for (const method of ["select", "eq", "order", "limit", "lt"]) { + chain[method] = vi.fn(() => chain); + } + chain.then = (resolve, reject) => Promise.resolve(result).then(resolve, reject); + return chain; +} + +describe("leaderboard snapshot loader", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns snapshot rows without touching the fallback view", async () => { + const snapshot = queryResult({ + data: [{ user_id: "u1", username: "alice", total_cost: 10 }], + error: null, + }); + mocks.from.mockReturnValue(snapshot); + + await expect( + loadLeaderboardEntries({ period: "week", limit: 5 }) + ).resolves.toMatchObject([{ user_id: "u1", username: "alice" }]); + + expect(mocks.from).toHaveBeenCalledOnce(); + expect(mocks.from).toHaveBeenCalledWith("leaderboard_snapshots"); + expect(snapshot.eq).toHaveBeenCalledWith("period", "week"); + }); + + it("falls back to the existing view before the migration is live", async () => { + const missingSnapshot = queryResult({ + data: null, + error: { message: "relation does not exist" }, + }); + const fallback = queryResult({ + data: [{ user_id: "u2", username: "bob", total_cost: 5 }], + error: null, + }); + mocks.from + .mockReturnValueOnce(missingSnapshot) + .mockReturnValueOnce(fallback); + + await expect( + loadLeaderboardEntries({ + period: "month", + region: "europe", + limit: 10, + }) + ).resolves.toMatchObject([{ user_id: "u2", username: "bob" }]); + + expect(mocks.from.mock.calls.map((call) => call[0])).toEqual([ + "leaderboard_snapshots", + "leaderboard_monthly", + ]); + expect(fallback.eq).toHaveBeenCalledWith("region", "europe"); + }); +}); diff --git a/apps/web/__tests__/lib/radar.test.ts b/apps/web/__tests__/lib/radar.test.ts new file mode 100644 index 00000000..10f07425 --- /dev/null +++ b/apps/web/__tests__/lib/radar.test.ts @@ -0,0 +1,62 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + rpc: vi.fn(), + single: vi.fn(), +})); + +vi.mock("next/cache", () => ({ + unstable_cache: unknown>(fn: T) => fn, +})); + +vi.mock("@/lib/supabase/service", () => ({ + getServiceClient: vi.fn(() => ({ + rpc: mocks.rpc, + })), +})); + +import { computeRadarScores } from "@/lib/radar"; + +describe("computeRadarScores", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.rpc.mockReturnValue({ single: mocks.single }); + }); + + it("reads the service-only profile snapshot RPC once", async () => { + mocks.single.mockResolvedValue({ + data: { + output: 91, + intensity: 72, + consistency: 63, + toolkit: 54, + community: 45, + }, + error: null, + }); + + await expect(computeRadarScores("user-1")).resolves.toEqual({ + output: 91, + intensity: 72, + consistency: 63, + toolkit: 54, + community: 45, + }); + expect(mocks.rpc).toHaveBeenCalledWith("get_profile_stats", { + p_user_id: "user-1", + }); + expect(mocks.rpc).toHaveBeenCalledOnce(); + expect(mocks.single).toHaveBeenCalledOnce(); + }); + + it("fails closed when the snapshot is unavailable", async () => { + mocks.single.mockResolvedValue({ + data: null, + error: { message: "snapshot missing" }, + }); + + await expect(computeRadarScores("user-1")).rejects.toThrow( + "snapshot missing" + ); + }); +}); diff --git a/apps/web/__tests__/performance/m6-rendering.test.tsx b/apps/web/__tests__/performance/m6-rendering.test.tsx new file mode 100644 index 00000000..f0e75a78 --- /dev/null +++ b/apps/web/__tests__/performance/m6-rendering.test.tsx @@ -0,0 +1,72 @@ +import { act, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { RecapPage } from "@/components/app/recap/RecapPage"; +import { RouteLoading } from "@/components/app/shared/RouteLoading"; +import SearchClient from "@/components/app/search/SearchClient"; +import type { RecapData } from "@/lib/utils/recap"; + +const recap: RecapData = { + total_cost: 42, + output_tokens: 12_000, + active_days: 3, + total_days: 7, + session_count: 8, + streak: 4, + primary_model: "Claude Sonnet", + contribution_data: [], + period_label: "My Week in Claude Code", + period: "week", + username: "alice", + is_public: true, +}; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("M6 server-provided route data", () => { + it("renders recap data immediately without a client fetch on mount", async () => { + const fetchMock = vi.spyOn(global, "fetch"); + + render(); + await act(async () => {}); + + expect(screen.getByText("$42.00")).toBeInTheDocument(); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("renders initial search results without a client fetch on mount", async () => { + const fetchMock = vi.spyOn(global, "fetch"); + + render( + , + ); + await act(async () => {}); + + expect(screen.getByRole("link", { name: /alice/i })).toHaveAttribute( + "href", + "/u/alice", + ); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("announces route-level loading state without exposing skeletons", () => { + render(); + + const status = screen.getByRole("status"); + expect(status).toHaveAttribute("aria-busy", "true"); + expect(status).toHaveTextContent("Loading settings"); + }); +}); diff --git a/apps/web/__tests__/unit/message-attachments.test.ts b/apps/web/__tests__/unit/message-attachments.test.ts new file mode 100644 index 00000000..44e8afee --- /dev/null +++ b/apps/web/__tests__/unit/message-attachments.test.ts @@ -0,0 +1,73 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + from: vi.fn(), + createSignedUrls: vi.fn(), +})); + +vi.mock("@/lib/supabase/service", () => ({ + getServiceClient: vi.fn(() => ({ + storage: { from: mocks.from }, + })), +})); + +import { + buildSignedMessageAttachmentBatches, + buildSignedMessageAttachments, +} from "@/lib/message-attachments"; + +const attachment = (bucket: string, path: string) => ({ + bucket, + path, + name: path.split("/").at(-1) ?? "file", + type: "image/png", + size: 123, +}); + +describe("message attachment URL signing", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.from.mockReturnValue({ createSignedUrls: mocks.createSignedUrls }); + mocks.createSignedUrls.mockImplementation(async (paths: string[]) => ({ + data: paths.map((path) => ({ + path, + signedUrl: `https://storage.test/${path}`, + error: null, + })), + error: null, + })); + }); + + it("batches all paths in the same bucket into one signing request", async () => { + const signed = await buildSignedMessageAttachmentBatches([ + { rawAttachments: [attachment("dm-attachments", "user-1/one.png")] }, + { rawAttachments: [attachment("dm-attachments", "user-1/two.png")] }, + ]); + + expect(mocks.from).toHaveBeenCalledOnce(); + expect(mocks.createSignedUrls).toHaveBeenCalledWith( + ["user-1/one.png", "user-1/two.png"], + 3600, + ); + expect(signed.map((group) => group[0]?.url)).toEqual([ + "https://storage.test/user-1/one.png", + "https://storage.test/user-1/two.png", + ]); + }); + + it("filters paths outside the message sender's storage prefix before signing", async () => { + const signed = await buildSignedMessageAttachments( + [ + attachment("dm-attachments", "user-1/mine.png"), + attachment("dm-attachments", "user-2/theirs.png"), + ], + "user-1", + ); + + expect(mocks.createSignedUrls).toHaveBeenCalledWith( + ["user-1/mine.png"], + 3600, + ); + expect(signed).toHaveLength(1); + }); +}); diff --git a/apps/web/__tests__/unit/migration-safety.test.ts b/apps/web/__tests__/unit/migration-safety.test.ts index b7c83356..7c60a73f 100644 --- a/apps/web/__tests__/unit/migration-safety.test.ts +++ b/apps/web/__tests__/unit/migration-safety.test.ts @@ -225,6 +225,97 @@ describe("Migration safety", () => { expect(/ON\s+public\.comment_reactions\s+FOR\s+INSERT[\s\S]*WITH\s+CHECK[\s\S]*public\.comments[\s\S]*comment_reactions\.comment_id/i.test(content)).toBe(true); }); + it("leaderboard and profile snapshots are private and refreshed atomically", () => { + const latest = getLatestMigrationMatching( + migrations, + /CREATE\s+TABLE\s+IF\s+NOT\s+EXISTS\s+public\.leaderboard_snapshots/i + ); + + expect(latest, "Expected the M4 snapshot migration").toBeTruthy(); + const content = latest!.content; + + expect(/ALTER\s+TABLE\s+public\.leaderboard_snapshots\s+ENABLE\s+ROW\s+LEVEL\s+SECURITY/i.test(content)).toBe(true); + expect(/ALTER\s+TABLE\s+public\.profile_stats_snapshots\s+ENABLE\s+ROW\s+LEVEL\s+SECURITY/i.test(content)).toBe(true); + expect(/REVOKE\s+ALL\s+ON\s+TABLE\s+public\.leaderboard_snapshots\s+FROM\s+anon/i.test(content)).toBe(true); + expect(/REVOKE\s+ALL\s+ON\s+TABLE\s+public\.profile_stats_snapshots\s+FROM\s+authenticated/i.test(content)).toBe(true); + expect(/pg_try_advisory_xact_lock/i.test(content)).toBe(true); + expect(/ON\s+CONFLICT\s*\(period,\s*user_id\)\s+DO\s+UPDATE/i.test(content)).toBe(true); + expect(/DELETE\s+FROM\s+public\.profile_stats_snapshots[\s\S]*refreshed_at\s*<>\s*v_refreshed_at/i.test(content)).toBe(true); + expect(/RANK\(\)\s+OVER\s*\(ORDER\s+BY\s+output_value\)\s*-\s*1/i.test(content)).toBe(true); + expect(/community_distribution/i.test(content)).toBe(true); + expect(/SELECT\s+COUNT\(\*\)\s+FROM\s+profile_values\s+AS\s+value/i.test(content)).toBe(false); + expect(/SELECT\s+public\.refresh_leaderboard_snapshots\(\)/i.test(content)).toBe(true); + expect(/'\*\/10 \* \* \* \*'/i.test(content)).toBe(true); + }); + + it("profile stats request RPC is a service-only one-row snapshot read", () => { + const latest = getLatestMigrationMatching( + migrations, + /CREATE\s+OR\s+REPLACE\s+FUNCTION\s+public\.get_profile_stats/i + ); + + expect(latest, "Expected get_profile_stats migration").toBeTruthy(); + const definition = latest!.content.slice( + latest!.content.search(/CREATE\s+OR\s+REPLACE\s+FUNCTION\s+public\.get_profile_stats/i) + ); + + expect(/SECURITY\s+DEFINER/i.test(definition)).toBe(true); + expect(/SET\s+search_path\s*=\s*''/i.test(definition)).toBe(true); + expect(/FROM\s+public\.profile_stats_snapshots/i.test(definition)).toBe(true); + expect(/FROM\s+public\.(daily_usage|follows|posts|kudos)/i.test(definition.split("$$;")[0])).toBe(false); + expect(/REVOKE\s+ALL\s+ON\s+FUNCTION\s+public\.get_profile_stats\(UUID\)\s+FROM\s+PUBLIC/i.test(definition)).toBe(true); + expect(/GRANT\s+EXECUTE\s+ON\s+FUNCTION\s+public\.get_profile_stats\(UUID\)\s+TO\s+service_role/i.test(definition)).toBe(true); + expect(/GRANT\s+EXECUTE[^;]+get_profile_stats[^;]+TO\s+(anon|authenticated)/i.test(definition)).toBe(false); + }); + + it("calculate_user_streak is set-based and keeps timezone and freeze semantics", () => { + const latest = getLatestMigrationMatching( + migrations, + /CREATE\s+OR\s+REPLACE\s+FUNCTION\s+public\.calculate_user_streak/i + ); + + expect(latest, "Expected calculate_user_streak migration").toBeTruthy(); + const content = latest!.content; + expect(/ROW_NUMBER\(\)\s+OVER\s*\(ORDER\s+BY\s+date\s+DESC\)/i.test(content)).toBe(true); + expect(/AT\s+TIME\s+ZONE\s+v_user_timezone/i.test(content)).toBe(true); + expect(/v_grace\s*:=\s*1\s*\+\s*p_freeze_days/i.test(content)).toBe(true); + expect(/^\s*LOOP\s*;?\s*$/im.test(content.slice(0, content.indexOf("CREATE OR REPLACE FUNCTION public.get_profile_stats")))).toBe(false); + }); + + it("adds a covering date-window leaderboard index", () => { + const latest = getLatestMigrationMatching( + migrations, + /idx_daily_usage_leaderboard_covering/i + ); + + expect(latest).toBeTruthy(); + expect(/ON\s+public\.daily_usage\s*\(date\s+DESC,\s*user_id\)\s*INCLUDE\s*\(cost_usd,\s*output_tokens\)/i.test(latest!.content)).toBe(true); + }); + + it("indexes the leaderboard snapshot user foreign key without removing the region index", () => { + const userIndex = getLatestMigrationMatching( + migrations, + /idx_leaderboard_snapshots_user_id/i + ); + const regionIndex = getLatestMigrationMatching( + migrations, + /idx_leaderboard_snapshots_period_region_cost/i + ); + + expect(userIndex).toBeTruthy(); + expect( + /CREATE\s+INDEX\s+IF\s+NOT\s+EXISTS\s+idx_leaderboard_snapshots_user_id\s+ON\s+public\.leaderboard_snapshots\s*\(user_id\)/i.test( + userIndex!.content + ) + ).toBe(true); + expect(regionIndex).toBeTruthy(); + expect( + /ON\s+public\.leaderboard_snapshots\s*\(period,\s*region,\s*total_cost\s+DESC,\s*user_id\)/i.test( + regionIndex!.content + ) + ).toBe(true); + }); + it("does not ship heuristic SQL repairs for historical Codex usage", () => { const abandonedRepairMigrations = migrations.filter((m) => /repair_(legacy|native).*codex_inflation|restore_claude_costs_after_codex_repair|repair_codex_only_v3/i.test(m.name) diff --git a/apps/web/__tests__/unit/profile-access.test.ts b/apps/web/__tests__/unit/profile-access.test.ts new file mode 100644 index 00000000..ae1414c7 --- /dev/null +++ b/apps/web/__tests__/unit/profile-access.test.ts @@ -0,0 +1,68 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + getAuthIdentity: vi.fn(), + profileSingle: vi.fn(), + followMaybeSingle: vi.fn(), +})); + +vi.mock("@/lib/supabase/auth", () => ({ + getAuthIdentity: mocks.getAuthIdentity, +})); + +vi.mock("@/lib/supabase/server", () => ({ + createClient: vi.fn(async () => ({ + from: vi.fn(() => ({ + select: vi.fn(() => ({ + eq: vi.fn(() => ({ + eq: vi.fn(() => ({ maybeSingle: mocks.followMaybeSingle })), + })), + })), + })), + })), +})); + +vi.mock("@/lib/supabase/service", () => ({ + getServiceClient: vi.fn(() => ({ + from: vi.fn(() => ({ + select: vi.fn(() => ({ + eq: vi.fn(() => ({ single: mocks.profileSingle })), + })), + })), + })), +})); + +import { getProfileAccessContext } from "@/lib/profile-access"; + +describe("profile access loading", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.getAuthIdentity.mockResolvedValue({ id: "viewer-1" }); + mocks.followMaybeSingle.mockResolvedValue({ data: { id: "follow-1" } }); + }); + + it("starts the username-scoped follow lookup without waiting for the profile", async () => { + let resolveProfile: ((value: unknown) => void) | undefined; + mocks.profileSingle.mockReturnValue( + new Promise((resolve) => { + resolveProfile = resolve; + }), + ); + + const accessPromise = getProfileAccessContext<{ id: string; is_public: boolean }>( + "alice", + "id, is_public", + ); + await vi.waitFor(() => expect(mocks.followMaybeSingle).toHaveBeenCalledOnce()); + + resolveProfile?.({ + data: { id: "profile-1", is_public: false }, + error: null, + }); + + await expect(accessPromise).resolves.toMatchObject({ + canView: true, + isFollowing: true, + }); + }); +}); diff --git a/apps/web/__tests__/unit/settings-payload.test.ts b/apps/web/__tests__/unit/settings-payload.test.ts index b03e6eef..c2bf5b04 100644 --- a/apps/web/__tests__/unit/settings-payload.test.ts +++ b/apps/web/__tests__/unit/settings-payload.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { buildProfileUpdatePayload } from "@/app/(app)/settings/page"; +import { buildProfileUpdatePayload } from "@/components/app/settings/SettingsClient"; const baseInput = { username: "alice", diff --git a/apps/web/__tests__/unit/supabase-auth.test.ts b/apps/web/__tests__/unit/supabase-auth.test.ts new file mode 100644 index 00000000..2c5bc6ae --- /dev/null +++ b/apps/web/__tests__/unit/supabase-auth.test.ts @@ -0,0 +1,82 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => { + const getClaims = vi.fn(); + const single = vi.fn(); + const eq = vi.fn(() => ({ single })); + const select = vi.fn(() => ({ eq })); + const from = vi.fn(() => ({ select })); + + return { getClaims, single, eq, select, from }; +}); + +vi.mock("@/lib/supabase/server", () => ({ + createClient: vi.fn(async () => ({ + auth: { getClaims: mocks.getClaims }, + })), +})); + +vi.mock("@/lib/supabase/service", () => ({ + getServiceClient: vi.fn(() => ({ from: mocks.from })), +})); + +import { getAuthContext, getAuthIdentity } from "@/lib/supabase/auth"; + +describe("Supabase auth context", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.getClaims.mockResolvedValue({ + data: { + claims: { + sub: "user-123", + email: "person@example.com", + }, + }, + error: null, + }); + mocks.single.mockResolvedValue({ + data: { + username: "person", + avatar_url: null, + display_name: "Person", + team_url: null, + team_favicon_url: null, + onboarding_completed: true, + streak_freezes: 2, + }, + error: null, + }); + }); + + it("derives a minimal verified identity from JWT claims", async () => { + await expect(getAuthIdentity()).resolves.toEqual({ + id: "user-123", + email: "person@example.com", + }); + }); + + it("loads the shell profile once for the verified subject", async () => { + await expect(getAuthContext()).resolves.toMatchObject({ + identity: { id: "user-123" }, + profile: { username: "person", streak_freezes: 2 }, + }); + + expect(mocks.from).toHaveBeenCalledOnce(); + expect(mocks.from).toHaveBeenCalledWith("users"); + expect(mocks.eq).toHaveBeenCalledWith("id", "user-123"); + expect(mocks.single).toHaveBeenCalledOnce(); + }); + + it("does not query a profile when verified claims have no subject", async () => { + mocks.getClaims.mockResolvedValueOnce({ + data: { claims: {} }, + error: null, + }); + + await expect(getAuthContext()).resolves.toEqual({ + identity: null, + profile: null, + }); + expect(mocks.from).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/__tests__/unit/supabase-middleware.test.ts b/apps/web/__tests__/unit/supabase-middleware.test.ts new file mode 100644 index 00000000..c065654f --- /dev/null +++ b/apps/web/__tests__/unit/supabase-middleware.test.ts @@ -0,0 +1,78 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { NextRequest } from "next/server"; + +type CookieToSet = { + name: string; + value: string; + options?: { httpOnly?: boolean; path?: string }; +}; + +const mocks = vi.hoisted(() => ({ + getClaims: vi.fn(), +})); + +vi.mock("@/lib/supabase/env", () => ({ + getMissingSupabaseBrowserEnv: vi.fn(() => []), + formatSupabaseEnvHelp: vi.fn(), +})); + +vi.mock("@supabase/ssr", () => ({ + createServerClient: vi.fn( + ( + _url: string, + _key: string, + options: { cookies: { setAll: (cookies: CookieToSet[]) => void } } + ) => { + options.cookies.setAll([ + { + name: "sb-test-auth-token", + value: "refreshed-token", + options: { httpOnly: true, path: "/" }, + }, + ]); + return { auth: { getClaims: mocks.getClaims } }; + } + ), +})); + +import { updateSession } from "@/lib/supabase/middleware"; + +describe("Supabase middleware auth", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.stubEnv("NEXT_PUBLIC_SUPABASE_URL", "https://test.supabase.co"); + vi.stubEnv("NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY", "test-key"); + }); + + it("uses verified claims and preserves refreshed cookies on redirects", async () => { + mocks.getClaims.mockResolvedValue({ + data: { claims: { sub: "user-123" } }, + error: null, + }); + + const response = await updateSession( + new NextRequest("https://straude.com/") + ); + + expect(response.status).toBe(307); + expect(response.headers.get("location")).toBe("https://straude.com/feed"); + expect(response.cookies.get("sb-test-auth-token")?.value).toBe( + "refreshed-token" + ); + expect(response.headers.get("Server-Timing")).toMatch(/^mw-auth;dur=\d+$/); + expect(mocks.getClaims).toHaveBeenCalledOnce(); + }); + + it("redirects an unverified protected request to login", async () => { + mocks.getClaims.mockResolvedValue({ data: null, error: null }); + + const response = await updateSession( + new NextRequest("https://straude.com/messages") + ); + + expect(response.headers.get("location")).toBe("https://straude.com/login"); + expect(response.cookies.get("sb-test-auth-token")?.value).toBe( + "refreshed-token" + ); + }); +}); diff --git a/apps/web/app/(app)/card/page.tsx b/apps/web/app/(app)/card/page.tsx index 9e6eca6e..ef116e79 100644 --- a/apps/web/app/(app)/card/page.tsx +++ b/apps/web/app/(app)/card/page.tsx @@ -1,193 +1,23 @@ -"use client"; - -import { useEffect, useState } from "react"; -import { Button } from "@/components/ui/Button"; -import { Copy, Check, ExternalLink } from "lucide-react"; -import Link from "next/link"; - -type ThemeId = "light" | "dark"; - -const THEMES: { id: ThemeId; label: string }[] = [ - { id: "light", label: "Light" }, - { id: "dark", label: "Dark" }, -]; - -function CopyBlock({ label, code }: { label: string; code: string }) { - const [copied, setCopied] = useState(false); - - function handleCopy() { - navigator.clipboard.writeText(code); - setCopied(true); - setTimeout(() => setCopied(false), 2000); - } - - return ( -
-
- {label} -
-
-
-          {code}
-        
- -
-
- ); -} - -export default function CardPage() { - const [username, setUsername] = useState(null); - const [isPublic, setIsPublic] = useState(true); - const [theme, setTheme] = useState("light"); - const [loading, setLoading] = useState(true); - - useEffect(() => { - async function load() { - const res = await fetch("/api/users/me"); - if (!res.ok) { - setLoading(false); - return; - } - - const profile = await res.json(); - - if (profile?.username) { - setUsername(profile.username); - setIsPublic(profile.is_public); - } - setLoading(false); - } - load(); - }, []); - - if (loading) { - return ( -
-
Loading...
-
- ); - } - - if (!username) { - return ( -
-
- Set a username in settings to get your card. -
- - - -
- ); +import CardClient from "@/components/app/card/CardClient"; +import { getAuthIdentity } from "@/lib/supabase/auth"; +import { getServiceClient } from "@/lib/supabase/service"; + +export default async function CardPage() { + const identity = await getAuthIdentity(); + if (!identity) { + return ; } - const baseUrl = "https://straude.com"; - const cardUrl = `${baseUrl}/api/card/${username}`; - const profileUrl = `${baseUrl}/u/${username}`; - - const markdownLight = `[![Straude Stats](${cardUrl})](${profileUrl})`; - const markdownDark = `[![Straude Stats](${cardUrl}?theme=dark)](${profileUrl})`; - const markdownAuto = ` - - - - Straude Stats - -`; + const { data: profile } = await getServiceClient() + .from("users") + .select("username, is_public") + .eq("id", identity.id) + .single(); return ( -
-
-

Stats Card

-

- Embed your Straude stats on your GitHub profile README. -

-
- - {!isPublic && ( -
- Your profile is private. The card will show a "private - profile" placeholder until you{" "} - - make it public - - . -
- )} - - {/* Theme toggle */} -
-
- Theme -
-
- {THEMES.map((t) => ( - - ))} -
-
- - {/* Preview */} -
-
- Preview -
-
- {/* eslint-disable-next-line @next/next/no-img-element */} - Straude Stats Card -
- -
- - {/* Embed snippets */} -
-
- Embed in your README -
- - - -
-
+ ); } diff --git a/apps/web/app/(app)/feed/page.tsx b/apps/web/app/(app)/feed/page.tsx index f3da9dc0..f3826976 100644 --- a/apps/web/app/(app)/feed/page.tsx +++ b/apps/web/app/(app)/feed/page.tsx @@ -1,6 +1,6 @@ import Link from "next/link"; import { createClient } from "@/lib/supabase/server"; -import { getAuthUser } from "@/lib/supabase/auth"; +import { getAuthIdentity } from "@/lib/supabase/auth"; import { FeedList } from "@/components/app/feed/FeedList"; import { enrichFeedPosts, getFeedCursor, getPendingPosts } from "@/lib/feed-enrichment"; import type { FeedPostRow } from "@/types"; @@ -45,7 +45,7 @@ export default async function FeedPage({ searchParams: Promise<{ tab?: string }>; }) { const params = await searchParams; - const user = await getAuthUser(); + const user = await getAuthIdentity(); const supabase = await createClient(); // Unauthenticated visitors can only see the global feed @@ -54,20 +54,24 @@ export default async function FeedPage({ ? params.tab : "global"; - // Feed + pending posts in parallel (independent queries) - const [{ data: feedData }, pendingPosts] = await Promise.all([ - supabase.rpc("get_feed", { - p_type: feedType, - p_user_id: user?.id ?? null, - p_limit: 20, + const feedPromise = supabase.rpc("get_feed", { + p_type: feedType, + p_user_id: user?.id ?? null, + p_limit: 20, + }); + // Start enrichment as soon as get_feed resolves instead of waiting for the + // independent pending-post query to finish. + const postsPromise = feedPromise.then(({ data: feedData }) => + enrichFeedPosts({ + posts: (feedData ?? []) as FeedPostRow[], + userId: user?.id ?? null, + userScopedClient: supabase, }), + ); + const [posts, pendingPosts] = await Promise.all([ + postsPromise, getPendingPosts(supabase, user?.id ?? null), ]); - const posts = await enrichFeedPosts({ - posts: (feedData ?? []) as FeedPostRow[], - userId: user?.id ?? null, - userScopedClient: supabase, - }); const nextCursor = getFeedCursor(posts, 20); const faqJsonLd = { diff --git a/apps/web/app/(app)/layout.tsx b/apps/web/app/(app)/layout.tsx index 9c16b852..0b57043e 100644 --- a/apps/web/app/(app)/layout.tsx +++ b/apps/web/app/(app)/layout.tsx @@ -1,8 +1,11 @@ -import { Suspense } from "react"; +import { cache, Suspense } from "react"; import Link from "next/link"; import { createClient } from "@/lib/supabase/server"; -import { getServiceClient } from "@/lib/supabase/service"; -import { getAuthUser } from "@/lib/supabase/auth"; +import { + getAuthContext, + getAuthIdentity, + type ShellProfile, +} from "@/lib/supabase/auth"; import { Sidebar } from "@/components/app/shared/Sidebar"; import { LazyRightSidebar } from "@/components/app/shared/RightSidebar"; import { InviteButton } from "@/components/app/profile/InviteButton"; @@ -16,16 +19,6 @@ import { firstRelation } from "@/lib/utils/first-relation"; import { loadUsageTotals } from "@/lib/data/usage-totals"; import type { DailyUsage } from "@/types"; -type ShellProfile = { - username: string | null; - avatar_url: string | null; - display_name: string | null; - team_url: string | null; - team_favicon_url: string | null; - onboarding_completed: boolean | null; - streak_freezes: number | null; -}; - type LatestPostRow = { id: string; title: string | null; @@ -33,7 +26,11 @@ type LatestPostRow = { daily_usage: Array> | null; }; -type SupabaseServerClient = Awaited>; +async function measure(operation: () => Promise): Promise<[T, number]> { + const start = Date.now(); + const result = await operation(); + return [result, Date.now() - start]; +} function formatLatestPosts(rows: LatestPostRow[]) { return rows @@ -49,7 +46,8 @@ function formatLatestPosts(rows: LatestPostRow[]) { .sort((a, b) => b.sortKey.localeCompare(a.sortKey)); } -async function loadLatestPosts(supabase: SupabaseServerClient, userId: string) { +const loadLatestPosts = cache(async (userId: string) => { + const supabase = await createClient(); // Order by daily_usage.date so backfills (which insert many posts in the same // second) still surface the most recent activity. !inner is required for // referencedTable ordering to apply to the parent rows. @@ -62,7 +60,7 @@ async function loadLatestPosts(supabase: SupabaseServerClient, userId: string) { .limit(3); return formatLatestPosts((data ?? []) as LatestPostRow[]); -} +}); function SidebarFallback({ profile }: { profile: ShellProfile | null }) { const username = profile?.username ?? null; @@ -152,7 +150,7 @@ async function DeferredSidebar({ .from("posts") .select("id", { count: "exact", head: true }) .eq("user_id", userId), - loadLatestPosts(supabase, userId), + loadLatestPosts(userId), loadUsageTotals(supabase, userId), supabase.rpc("calculate_user_streak", { p_user_id: userId, @@ -188,15 +186,16 @@ async function PhotoNudge({ }) { if (onboardingIncomplete) return null; - const supabase = await createClient(); const [latestPosts, photoAchievementRes] = await Promise.all([ - loadLatestPosts(supabase, userId), - supabase - .from("user_achievements") - .select("id") - .eq("user_id", userId) - .eq("achievement_slug", "first-photo") - .maybeSingle(), + loadLatestPosts(userId), + createClient().then((supabase) => + supabase + .from("user_achievements") + .select("id") + .eq("user_id", userId) + .eq("achievement_slug", "first-photo") + .maybeSingle(), + ), ]); if (latestPosts.length === 0 || photoAchievementRes.data) return null; @@ -216,9 +215,10 @@ export default async function AppLayout({ }: { children: React.ReactNode; }) { - const user = await getAuthUser(); + const perfTiming = process.env.PERF_TIMING === "1"; + const [identity, authMs] = await measure(getAuthIdentity); // If not logged in: allow public pages, redirect others to login - if (!user) { + if (!identity) { // This check runs server-side as a safety net alongside proxy.ts // Public pages render with a guest layout below return ( @@ -238,19 +238,13 @@ export default async function AppLayout({ ); } - const db = getServiceClient(); - const { data: profileData } = await db - .from("users") - .select("username, avatar_url, display_name, team_url, team_favicon_url, onboarding_completed, streak_freezes") - .eq("id", user.id) - .single(); + const [{ profile }, profileMs] = await measure(getAuthContext); - const profile = profileData as ShellProfile | null; const onboardingIncomplete = !profile?.onboarding_completed; const leftPanel = ( }> - + ); @@ -258,6 +252,15 @@ export default async function AppLayout({ return ( + {perfTiming && ( +