diff --git a/e2e/dashboard-widgets.spec.js b/e2e/dashboard-widgets.spec.js index 45f128059..18d748247 100644 --- a/e2e/dashboard-widgets.spec.js +++ b/e2e/dashboard-widgets.spec.js @@ -224,7 +224,10 @@ test("contribution graph range buttons request a new range", async ({ page }) => await page.goto("/dashboard", { waitUntil: "load" }); await expect(page.getByRole("heading", { name: /dashboard/i })).toBeVisible({ timeout: 30000 }); - await page.getByRole("button", { name: "Show 90-day range" }).click(); + await page + .locator("#contribution-activity") + .getByRole("button", { name: "Show 90-day range" }) + .click(); await expect.poll(() => contributionRequests.some((url) => url.includes("days=90")), { timeout: 15000 }).toBe(true); }); @@ -349,4 +352,4 @@ function mockMetricResponse(url) { }; } return {}; -} \ No newline at end of file +} diff --git a/playwright.config.mjs b/playwright.config.mjs index 5440f9667..d33706f91 100644 --- a/playwright.config.mjs +++ b/playwright.config.mjs @@ -2,6 +2,8 @@ import { defineConfig, devices } from "@playwright/test"; const PORT = Number(process.env.PORT ?? 3000); const baseURL = process.env.PLAYWRIGHT_BASE_URL ?? `http://127.0.0.1:${PORT}`; +const prepareStandaloneCommand = + "node -e \"const fs=require('fs'); fs.cpSync('public','.next/standalone/public',{recursive:true,force:true}); fs.cpSync('.next/static','.next/standalone/.next/static',{recursive:true,force:true});\""; export default defineConfig({ testDir: "./e2e", @@ -22,7 +24,7 @@ export default defineConfig({ webServer: { command: process.env.PLAYWRIGHT_SERVER_MODE === "start" - ? "node .next/standalone/server.js" + ? `${prepareStandaloneCommand} && node .next/standalone/server.js` : `node node_modules/next/dist/bin/next dev -H 127.0.0.1 -p ${PORT}`, url: baseURL, reuseExistingServer: !process.env.CI, diff --git a/src/app/api/local-coding/sync/route.ts b/src/app/api/local-coding/sync/route.ts index ea755f7de..39e0f8be7 100644 --- a/src/app/api/local-coding/sync/route.ts +++ b/src/app/api/local-coding/sync/route.ts @@ -108,7 +108,19 @@ export async function POST(req: NextRequest) { ); } - if ((existingCount || 0) + newSessions.length > MAX_SESSIONS_PER_USER) { + const incomingDates = [...new Set(newSessions.map((session) => session.date))]; + const { data: existingSessionsForDates } = await supabaseAdmin + .from("local_coding_sessions") + .select("date") + .eq("user_id", userId) + .in("date", incomingDates); + + const existingDateSet = new Set( + (existingSessionsForDates ?? []).map((session: { date: string }) => session.date) + ); + const newDateCount = incomingDates.filter((date) => !existingDateSet.has(date)).length; + + if ((existingCount || 0) + newDateCount > MAX_SESSIONS_PER_USER) { return Response.json( { error: `Session limit reached. Maximum ${MAX_SESSIONS_PER_USER} sessions per user.` }, { status: 400 } diff --git a/src/app/dashboard/layout.tsx b/src/app/dashboard/layout.tsx index 2cd3aae9f..9f7693b6a 100644 --- a/src/app/dashboard/layout.tsx +++ b/src/app/dashboard/layout.tsx @@ -6,6 +6,18 @@ import { useRouter } from "next/navigation"; import { toast } from "sonner"; import type { ReactNode } from "react"; +async function hasActiveSession(fetcher: typeof window.fetch) { + try { + const response = await fetcher("/api/auth/session", { cache: "no-store" }); + if (!response.ok) return false; + + const session = await response.json(); + return Boolean(session?.user || session?.githubId || session?.accessToken); + } catch { + return false; + } +} + export default function DashboardLayout({ children }: { children: ReactNode }) { const { status } = useSession({ required: true }); const router = useRouter(); @@ -16,9 +28,14 @@ export default function DashboardLayout({ children }: { children: ReactNode }) { const response = await originalFetch(...args); if (response.status === 401) { const cloned = response.clone(); - toast.error("Session expired. Please sign in again."); - await signOut({ redirect: false }); - router.push("/auth/signin"); + const sessionStillActive = await hasActiveSession(originalFetch); + + if (!sessionStillActive) { + toast.error("Session expired. Please sign in again."); + await signOut({ redirect: false }); + router.push("/auth/signin"); + } + return cloned; } return response; @@ -31,4 +48,4 @@ export default function DashboardLayout({ children }: { children: ReactNode }) { if (status === "loading") return null; return <>{children}; -} \ No newline at end of file +} diff --git a/src/middleware.ts b/src/middleware.ts index 2db425352..789fad332 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -200,27 +200,26 @@ async function checkRateLimit(identifier: string, limit: number) { } export async function middleware(req: NextRequest) { - const token = await getToken({ - req, - secret: process.env.NEXTAUTH_SECRET, - }); - - // Protect dashboard and settings routes const pathname = req.nextUrl.pathname; + const token = await getToken({ req, secret: process.env.NEXTAUTH_SECRET }); const protectedRoutes = ["/dashboard", "/settings"]; - - const isProtectedRoute = protectedRoutes.some((route) => - pathname.startsWith(route) + const isProtectedRoute = protectedRoutes.some( + (route) => pathname === route || pathname.startsWith(`${route}/`) ); - if (isProtectedRoute && !token) { - return NextResponse.redirect(new URL("/", req.url)); - } + if (isProtectedRoute) { + if (!token) { + const url = req.nextUrl.clone(); + url.pathname = "/"; + url.search = ""; + return NextResponse.redirect(url); + } - const githubId = - typeof token?.githubId === "string" ? token.githubId : null; + return NextResponse.next(); + } + const githubId = typeof token?.githubId === "string" ? token.githubId : null; const identifier = githubId ? `user:${githubId}` : `ip:${getIp(req)}`; const limit = githubId @@ -266,9 +265,10 @@ export async function middleware(req: NextRequest) { export const config = { matcher: [ + "/dashboard", "/dashboard/:path*", + "/settings", "/settings/:path*", "/api/metrics/:path*", ], }; - diff --git a/test/local-coding-sync.test.ts b/test/local-coding-sync.test.ts index 74e7e4ae4..e8cb26c14 100644 --- a/test/local-coding-sync.test.ts +++ b/test/local-coding-sync.test.ts @@ -11,6 +11,9 @@ const mockSelect = vi.fn().mockReturnValue({ eq: mockEq }); const mockKeyLookupOr = vi.fn().mockReturnValue({ single: mockSingle }); const mockUpdateOr = vi.fn().mockResolvedValue({ error: null }); const mockUpdate = vi.fn().mockReturnValue({ or: mockUpdateOr }); +const mockSessionCountEq = vi.fn(); +const mockExistingDatesIn = vi.fn(); +const mockExistingDatesEq = vi.fn().mockReturnValue({ in: mockExistingDatesIn }); const mockFrom = vi.fn().mockReturnValue({ select: mockSelect, update: mockUpdate, @@ -31,6 +34,9 @@ describe("Local Coding Sync POST API Endpoint", () => { data: { user_id: "test-user-id" }, error: null, }); + mockSessionCountEq.mockResolvedValue({ count: 5, data: null, error: null }); + mockExistingDatesIn.mockResolvedValue({ data: [], error: null }); + mockExistingDatesEq.mockReturnValue({ in: mockExistingDatesIn }); // Setup standard mock behavior mockFrom.mockImplementation((table: string) => { @@ -46,8 +52,11 @@ describe("Local Coding Sync POST API Endpoint", () => { } if (table === "local_coding_sessions") { return { - select: vi.fn().mockReturnValue({ - eq: vi.fn().mockResolvedValue({ count: 5, data: null, error: null }), + select: vi.fn((_columns: string, options?: { count?: string; head?: boolean }) => { + if (options?.count) { + return { eq: mockSessionCountEq }; + } + return { eq: mockExistingDatesEq }; }), }; } @@ -179,8 +188,15 @@ describe("Local Coding Sync POST API Endpoint", () => { } if (table === "local_coding_sessions") { return { - select: vi.fn().mockReturnValue({ - eq: vi.fn().mockResolvedValue({ count: 360, data: null, error: null }), + select: vi.fn((_columns: string, options?: { count?: string; head?: boolean }) => { + if (options?.count) { + return { eq: vi.fn().mockResolvedValue({ count: 360, data: null, error: null }) }; + } + return { + eq: vi.fn().mockReturnValue({ + in: vi.fn().mockResolvedValue({ data: [], error: null }), + }), + }; }), }; } @@ -208,6 +224,42 @@ describe("Local Coding Sync POST API Endpoint", () => { expect(body.error).toContain("Session limit reached"); }); + it("allows near-limit resyncs when incoming dates already exist", async () => { + const existingDates = Array.from({ length: 10 }, (_, i) => `2026-05-${10 + i}`); + + mockSessionCountEq.mockResolvedValue({ count: 360, data: null, error: null }); + mockExistingDatesIn.mockResolvedValue({ + data: existingDates.map((date) => ({ date })), + error: null, + }); + + const sessions = existingDates.map((date) => ({ + date, + totalSeconds: 100, + })); + const req = new NextRequest("http://localhost/api/local-coding/sync", { + method: "POST", + headers: { + Authorization: "Bearer test-key", + }, + body: JSON.stringify({ sessions }), + }); + + const res = await POST(req); + + expect(res.status).toBe(200); + expect(mockExistingDatesIn).toHaveBeenCalledWith("date", existingDates); + expect(mockRpc).toHaveBeenCalledWith("batch_upsert_sessions", { + sessions: existingDates.map((date) => ({ + user_id: "test-user-id", + date, + total_seconds: 100, + file_count: 0, + project_count: 0, + })), + }); + }); + it("successfully syncs sessions via batch_upsert_sessions RPC", async () => { const sessions = [ { date: "2026-05-27", totalSeconds: 3600, fileCount: 12, projectCount: 3 },