diff --git a/src/app/api/local-coding/sync/route.ts b/src/app/api/local-coding/sync/route.ts index c1c22940..5cf8f000 100644 --- a/src/app/api/local-coding/sync/route.ts +++ b/src/app/api/local-coding/sync/route.ts @@ -122,11 +122,12 @@ export async function POST(req: NextRequest) { project_count: session.projectCount || 0, })); - const { error: upsertError } = await supabaseAdmin - .from("local_coding_sessions") - .upsert(records, { onConflict: "user_id,date" }); + const { error: upsertError } = await supabaseAdmin.rpc("batch_upsert_sessions", { + sessions: records, + }); if (upsertError) { + console.error("Failed to sync sessions via RPC:", upsertError); return Response.json({ error: "Failed to sync sessions" }, { status: 500 }); } diff --git a/supabase/migrations/20260527000000_add_batch_upsert_sessions_rpc.sql b/supabase/migrations/20260527000000_add_batch_upsert_sessions_rpc.sql new file mode 100644 index 00000000..a0cd7583 --- /dev/null +++ b/supabase/migrations/20260527000000_add_batch_upsert_sessions_rpc.sql @@ -0,0 +1,25 @@ +-- Refactor local coding sessions sync to use a database transaction function +-- This prevents partial failures and cardinality violation errors on duplicate date entries + +create or replace function batch_upsert_sessions(sessions jsonb) +returns void as $$ +declare + session_record jsonb; +begin + for session_record in select * from jsonb_array_elements(sessions) loop + insert into local_coding_sessions (user_id, date, total_seconds, file_count, project_count) + values ( + (session_record->>'user_id'), + (session_record->>'date')::date, + (session_record->>'total_seconds')::integer, + coalesce((session_record->>'file_count')::integer, 0), + coalesce((session_record->>'project_count')::integer, 0) + ) + on conflict (user_id, date) do update set + total_seconds = excluded.total_seconds, + file_count = excluded.file_count, + project_count = excluded.project_count, + updated_at = now(); + end loop; +end; +$$ language plpgsql security definer; diff --git a/supabase/schema.sql b/supabase/schema.sql index 248748f7..a4165500 100644 --- a/supabase/schema.sql +++ b/supabase/schema.sql @@ -76,3 +76,28 @@ alter table user_github_achievements enable row level security; create policy "user_github_achievements_select_own" on user_github_achievements for select using (user_id = auth.uid()::text); + +-- Refactor local coding sessions sync to use a database transaction function +create or replace function batch_upsert_sessions(sessions jsonb) +returns void as $$ +declare + session_record jsonb; +begin + for session_record in select * from jsonb_array_elements(sessions) loop + insert into local_coding_sessions (user_id, date, total_seconds, file_count, project_count) + values ( + (session_record->>'user_id'), + (session_record->>'date')::date, + (session_record->>'total_seconds')::integer, + coalesce((session_record->>'file_count')::integer, 0), + coalesce((session_record->>'project_count')::integer, 0) + ) + on conflict (user_id, date) do update set + total_seconds = excluded.total_seconds, + file_count = excluded.file_count, + project_count = excluded.project_count, + updated_at = now(); + end loop; +end; +$$ language plpgsql security definer; + diff --git a/test/local-coding-sync.test.ts b/test/local-coding-sync.test.ts new file mode 100644 index 00000000..f1ec28b7 --- /dev/null +++ b/test/local-coding-sync.test.ts @@ -0,0 +1,257 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { POST } from "@/app/api/local-coding/sync/route"; +import { NextRequest } from "next/server"; + +// Mock Supabase admin client methods +const mockRpc = vi.fn(); +const mockSingle = vi.fn(); +const mockEq = vi.fn().mockReturnValue({ single: mockSingle }); +const mockSelect = vi.fn().mockReturnValue({ eq: mockEq }); +const mockUpdate = vi.fn().mockReturnValue({ eq: vi.fn().mockResolvedValue({ error: null }) }); +const mockFrom = vi.fn().mockReturnValue({ + select: mockSelect, + update: mockUpdate, +}); + +vi.mock("@/lib/supabase", () => ({ + supabaseAdmin: { + from: (table: string) => mockFrom(table), + rpc: (name: string, params: any) => mockRpc(name, params), + }, +})); + +describe("Local Coding Sync POST API Endpoint", () => { + beforeEach(() => { + vi.clearAllMocks(); + + mockSingle.mockResolvedValue({ + data: { user_id: "test-user-id" }, + error: null, + }); + + // Setup standard mock behavior + mockFrom.mockImplementation((table: string) => { + if (table === "local_coding_api_keys") { + return { + select: vi.fn().mockReturnValue({ + eq: vi.fn().mockReturnValue({ + single: mockSingle, + }), + }), + update: vi.fn().mockReturnValue({ + eq: vi.fn().mockResolvedValue({ error: null }), + }), + }; + } + if (table === "local_coding_sessions") { + return { + select: vi.fn().mockReturnValue({ + eq: vi.fn().mockResolvedValue({ count: 5, data: null, error: null }), + }), + }; + } + return { + select: vi.fn().mockReturnValue({ + eq: vi.fn().mockResolvedValue({ data: null, error: null }), + }), + }; + }); + + mockRpc.mockResolvedValue({ data: null, error: null }); + }); + + it("rejects request if Authorization header is missing", async () => { + const req = new NextRequest("http://localhost/api/local-coding/sync", { + method: "POST", + }); + const res = await POST(req); + expect(res.status).toBe(401); + expect(await res.json()).toEqual({ error: "API key required" }); + }); + + it("rejects request if API key is invalid", async () => { + mockSingle.mockResolvedValue({ data: null, error: { message: "Not found" } }); + + const req = new NextRequest("http://localhost/api/local-coding/sync", { + method: "POST", + headers: { + Authorization: "Bearer invalid-key", + }, + }); + const res = await POST(req); + expect(res.status).toBe(401); + expect(await res.json()).toEqual({ error: "Invalid API key" }); + }); + + it("rejects request if body is not valid JSON", async () => { + const req = new NextRequest("http://localhost/api/local-coding/sync", { + method: "POST", + headers: { + Authorization: "Bearer test-key", + }, + body: "invalid-json", + }); + const res = await POST(req); + expect(res.status).toBe(400); + expect(await res.json()).toEqual({ error: "Invalid JSON" }); + }); + + it("rejects request if sessions array is missing or empty", async () => { + const req = new NextRequest("http://localhost/api/local-coding/sync", { + method: "POST", + headers: { + Authorization: "Bearer test-key", + }, + body: JSON.stringify({}), + }); + const res = await POST(req); + expect(res.status).toBe(400); + expect(await res.json()).toEqual({ error: "Sessions array is required" }); + }); + + it("rejects request if sessions array exceeds maximum limit", async () => { + const sessions = Array.from({ length: 101 }, () => ({ + date: "2026-05-27", + 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(400); + const body = await res.json(); + expect(body.error).toContain("Too many sessions"); + }); + + it("rejects request if any session data has an invalid date", async () => { + const sessions = [ + { date: "2026-05-27", totalSeconds: 100 }, + { date: "invalid-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(400); + expect(await res.json()).toEqual({ error: "Invalid session data found in array" }); + }); + + it("rejects request if any session data has negative seconds", async () => { + const sessions = [{ date: "2026-05-27", totalSeconds: -50 }]; + 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(400); + expect(await res.json()).toEqual({ error: "Invalid session data found in array" }); + }); + + it("rejects request if new sessions exceed user maximum limit", async () => { + // 360 existing sessions + 10 new sessions = 370 > 365 + mockFrom.mockImplementation((table: string) => { + if (table === "local_coding_api_keys") { + return { + select: vi.fn().mockReturnValue({ + eq: vi.fn().mockReturnValue({ + single: mockSingle.mockResolvedValue({ + data: { user_id: "test-user-id" }, + error: null, + }), + }), + }), + update: vi.fn().mockReturnValue({ + eq: vi.fn().mockResolvedValue({ error: null }), + }), + }; + } + if (table === "local_coding_sessions") { + return { + select: vi.fn().mockReturnValue({ + eq: vi.fn().mockResolvedValue({ count: 360, data: null, error: null }), + }), + }; + } + return { + select: vi.fn().mockReturnValue({ + eq: vi.fn().mockResolvedValue({ data: null, error: null }), + }), + }; + }); + + const sessions = Array.from({ length: 10 }, (_, i) => ({ + date: `2026-05-${10 + i}`, + 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(400); + const body = await res.json(); + expect(body.error).toContain("Session limit reached"); + }); + + it("successfully syncs sessions via batch_upsert_sessions RPC", async () => { + const sessions = [ + { date: "2026-05-27", totalSeconds: 3600, fileCount: 12, projectCount: 3 }, + ]; + 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(await res.json()).toEqual({ + success: true, + synced: 1, + message: "Sessions synced successfully", + }); + + expect(mockRpc).toHaveBeenCalledWith("batch_upsert_sessions", { + sessions: [ + { + user_id: "test-user-id", + date: "2026-05-27", + total_seconds: 3600, + file_count: 12, + project_count: 3, + }, + ], + }); + }); + + it("returns 500 error if batch_upsert_sessions RPC fails", async () => { + mockRpc.mockResolvedValue({ data: null, error: { message: "DB Error" } }); + + const sessions = [{ date: "2026-05-27", totalSeconds: 120 }]; + 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(500); + expect(await res.json()).toEqual({ error: "Failed to sync sessions" }); + }); +});