Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions src/app/api/local-coding/sync/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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;
25 changes: 25 additions & 0 deletions supabase/schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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;

257 changes: 257 additions & 0 deletions test/local-coding-sync.test.ts
Original file line number Diff line number Diff line change
@@ -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" });
});
});
Loading