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
5 changes: 5 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,11 @@ This document serves as the primary context and rulebook for all AI agents worki
- **Supabase:** PostgreSQL database and Authentication.
- **Database Logic:** Relational data (Books <-> Sightings <-> Users).

### Package Management

- **Tool:** `yarn` (NOT `npm`).
- **Enforcement:** All install/run commands should use `yarn`.

## Key Mechanics

### 1. Oldest-to-Newest Ledger
Expand Down
149 changes: 149 additions & 0 deletions __tests__/api/generate-auth.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
import { POST } from "@/app/api/books/generate/route"
import { createRouteHandlerClient } from "@supabase/auth-helpers-nextjs"
import { cookies } from "next/headers"
import { NextResponse } from "next/server"

Copilot AI Dec 21, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unused import NextResponse.

Suggested change
import { NextResponse } from "next/server"

Copilot uses AI. Check for mistakes.

// Mock Supabase
jest.mock("@supabase/auth-helpers-nextjs", () => ({
createRouteHandlerClient: jest.fn(),
}))

jest.mock("@supabase/supabase-js", () => ({
createClient: jest.fn(() => ({
from: jest.fn(() => ({
insert: jest.fn(() => ({
select: jest.fn(() => ({
single: jest.fn(() => ({
data: { id: "test-book-id" },
error: null,
})),
})),
})),
})),
})),
}))

// Mock Next.js headers
jest.mock("next/headers", () => ({
cookies: jest.fn(),
}))

// Mock internal libs
jest.mock("@/lib/id_generator", () => ({
generateBookId: jest.fn().mockResolvedValue("TEST-CODE-123"),
}))
jest.mock("@/lib/book-utils", () => ({
parseBookMetadata: jest.fn(() => ({
title: "Test Book",
author: "Test Author",
cover_url: "http://example.com/cover.jpg",
isbn: "1234567890",
})),
}))

describe("Generate API Auth", () => {
const mockCookies = {
getAll: jest.fn(),
get: jest.fn(),
}
const mockSupabase = {
auth: {
getUser: jest.fn(),
getSession: jest.fn(),
},
from: jest.fn(),
}

beforeEach(() => {
jest.clearAllMocks();
(cookies as jest.Mock).mockResolvedValue(mockCookies);

Copilot AI Dec 21, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The cookies() mock should return a Promise to match the actual implementation which uses await cookies(). The mock is currently missing the Promise wrapper, which could cause runtime errors or improper test behavior. Consider changing line 59 to: (cookies as jest.Mock).mockResolvedValue(mockCookies);

Copilot uses AI. Check for mistakes.
(createRouteHandlerClient as jest.Mock).mockReturnValue(mockSupabase);
const { createClient } = require("@supabase/supabase-js");
(createClient as jest.Mock).mockReturnValue(mockSupabase);

// Default valid book generation mocks
const mockInsertSightings = jest.fn().mockResolvedValue({ error: null });
const mockInsertBooks = jest.fn().mockReturnValue({
select: jest.fn().mockReturnValue({
single: jest.fn().mockResolvedValue({ data: { id: "book-123" }, error: null })
})
});

mockSupabase.from.mockImplementation((table) => {
if (table === 'books') {
return { insert: mockInsertBooks }
}
if (table === 'sightings') {
return { insert: mockInsertSightings }
}
return { select: jest.fn() }
})

Copilot AI Dec 21, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Avoid automated semicolon insertion (90% of all statements in the enclosing function have an explicit semicolon).

Suggested change
})
});

Copilot uses AI. Check for mistakes.

// Attach to mockSupabase for easy access in tests (a bit hacky but works for this scope)
// @ts-ignore
mockSupabase._mockInsertSightings = mockInsertSightings;
// @ts-ignore
mockSupabase._mockInsertBooks = mockInsertBooks;
})

it("should capture user_id when user is authenticated via getUser", async () => {
// Mock authenticated user
const mockUser = { id: "user-123", email: "test@example.com" }
mockSupabase.auth.getUser.mockResolvedValue({
data: { user: mockUser },
error: null,
})

const request = {
json: jest.fn().mockResolvedValue({
book: { title: "Test Book" },
location: { lat: 10, long: 20 },
anonymousId: "anon-123",
}),
url: "http://localhost/api/books/generate",
} as unknown as Request

await POST(request)

// Verify sightings insert includes user_id
expect(mockSupabase.from).toHaveBeenCalledWith("sightings")
// @ts-ignore
expect(mockSupabase._mockInsertSightings).toHaveBeenCalled()
// @ts-ignore
const sightingInsertCall = mockSupabase._mockInsertSightings.mock.calls[0][0]
expect(sightingInsertCall).toEqual(expect.objectContaining({
user_id: "user-123",
anonymous_id: null
}))
})

it("should use anonymous_id when user is not authenticated", async () => {
// Mock no user
mockSupabase.auth.getUser.mockResolvedValue({
data: { user: null },
error: null,
})

const request = {
json: jest.fn().mockResolvedValue({
book: { title: "Test Book" },
location: { lat: 10, long: 20 },
anonymousId: "anon-123",
}),
url: "http://localhost/api/books/generate",
} as unknown as Request

await POST(request)

// Verify sightings insert includes anonymous_id and null user_id
expect(mockSupabase.from).toHaveBeenCalledWith("sightings")
// @ts-ignore
expect(mockSupabase._mockInsertSightings).toHaveBeenCalled()
// @ts-ignore
const sightingInsertCall = mockSupabase._mockInsertSightings.mock.calls[0][0]
expect(sightingInsertCall).toEqual(expect.objectContaining({
user_id: null,
anonymous_id: "anon-123"
}))
})
})
1 change: 1 addition & 0 deletions __tests__/app/api/books/generate/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ jest.mock("@supabase/auth-helpers-nextjs", () => ({
createRouteHandlerClient: jest.fn().mockReturnValue({
auth: {
getSession: jest.fn().mockResolvedValue({ data: { session: null } }),
getUser: jest.fn().mockResolvedValue({ data: { user: null } }),
},
}),
}))
Expand Down
9 changes: 9 additions & 0 deletions __tests__/app/api/sightings/claim/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,11 @@ describe("POST /api/sightings/claim", () => {
},
},
}),
getUser: jest.fn().mockResolvedValue({
data: {
user: { id: "test-user-id" },
},
}),
},
}
;(createRouteHandlerClient as jest.Mock).mockReturnValue(mockSupabase)
Expand Down Expand Up @@ -76,6 +81,10 @@ describe("POST /api/sightings/claim", () => {

it("should return 401 if user is not authenticated", async () => {
mockSupabase.auth.getSession.mockResolvedValue({ data: { session: null } })
mockSupabase.auth.getUser.mockResolvedValue({
data: { user: null },
error: new Error("Auth Error"),
})
const request = new Request("http://localhost:3000/api/sightings/claim", {
method: "POST",
body: JSON.stringify({ anonymousId: "123e4567-e89b-12d3-a456-426614174000" }),
Expand Down
6 changes: 3 additions & 3 deletions app/api/books/generate/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,10 @@ export async function POST(request: Request) {
const cookieStore = await cookies()
const supabase = createRouteHandlerClient({ cookies: () => cookieStore } as any)
const {
data: { session },
} = await supabase.auth.getSession()
data: { user },
} = await supabase.auth.getUser()

const userId = session?.user?.id || null
const userId = user?.id || null

// 2. Generate Code
const code = await generateBookId(lat, long)
Expand Down
21 changes: 9 additions & 12 deletions app/api/sightings/claim/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,24 +17,21 @@ export async function POST(request: Request) {
}

// 1. Get authenticated user
const cookieStore = cookies()
const cookieStore = await cookies()
// Use ANON_KEY for session handling (respects RLS)
const supabase = createRouteHandlerClient(
{ cookies: () => cookieStore },
{
supabaseUrl: process.env.NEXT_PUBLIC_SUPABASE_URL,
supabaseKey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY,
}
)
const supabase = createRouteHandlerClient({ cookies: () => cookieStore } as any, {
supabaseUrl: process.env.NEXT_PUBLIC_SUPABASE_URL,
supabaseKey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY,
})
const {
data: { session },
} = await supabase.auth.getSession()
data: { user },
} = await supabase.auth.getUser()

if (!session?.user) {
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
}

const userId = session.user.id
const userId = user.id

// 2. Claim sightings using centralized Admin Client
// 3. Update sightings
Expand Down
12 changes: 12 additions & 0 deletions jest.setup.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ if (typeof global.Request === "undefined") {
constructor(input, init) {
return { url: input, input, init, json: () => Promise.resolve({}) }
}
static json(data) {
return { json: () => Promise.resolve(data) }
}
}
}

Expand All @@ -26,5 +29,14 @@ if (typeof global.Response === "undefined") {
json: () => Promise.resolve(body),
}
}
static json(body, init) {
return {
body,
init,
ok: true,
status: init?.status || 200,
json: () => Promise.resolve(body),
}
}
}
}