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
36 changes: 36 additions & 0 deletions __tests__/api-client.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { fetchFlowchart, ApiError } from '@/lib/api-client'

describe('api-client error handling', () => {
const originalFetch = global.fetch

afterEach(() => {
jest.restoreAllMocks()
global.fetch = originalFetch
})

it('throws an ApiError with the server message when the response is not ok', async () => {
jest.spyOn(global, 'fetch').mockResolvedValue(
new Response(JSON.stringify({ error: 'Rate limit exceeded' }), { status: 429 })
)

await expect(fetchFlowchart('fc-1')).rejects.toMatchObject({
message: 'Rate limit exceeded',
status: 429,
})
})

it('throws a fallback ApiError when the error response has no JSON body', async () => {
jest.spyOn(global, 'fetch').mockResolvedValue(new Response('not json', { status: 500 }))

await expect(fetchFlowchart('fc-1')).rejects.toBeInstanceOf(ApiError)
await expect(fetchFlowchart('fc-1')).rejects.toMatchObject({ status: 500 })
})

it('resolves with the parsed JSON when the response is ok', async () => {
jest.spyOn(global, 'fetch').mockResolvedValue(
new Response(JSON.stringify({ id: 'fc-1', code: 'x' }), { status: 200 })
)

await expect(fetchFlowchart('fc-1')).resolves.toMatchObject({ id: 'fc-1', code: 'x' })
})
})
42 changes: 42 additions & 0 deletions __tests__/flowcharts-delete-route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { DELETE } from '@/app/api/flowcharts/[id]/route'
import { createQueryMock } from './helpers/supabase-query-mock'

let flowchartsQuery: ReturnType<typeof createQueryMock>

jest.mock('@/lib/supabase/server', () => ({
createClient: jest.fn(async () => ({
auth: { getUser: jest.fn(async () => ({ data: { user: { id: 'user-1' } }, error: null })) },
from: () => flowchartsQuery,
})),
}))

describe('DELETE /api/flowcharts/[id]', () => {
beforeEach(() => {
flowchartsQuery = createQueryMock()
})

it('returns 404 when the caller does not own the flowchart', async () => {
flowchartsQuery.queueResult({ data: null, error: { code: 'PGRST116' } })

const response = await DELETE(
new Request('http://localhost/api/flowcharts/fc-1', { method: 'DELETE' }),
{ params: Promise.resolve({ id: 'fc-1' }) }
)

expect(response.status).toBe(404)
})

it('deletes and returns success when the caller owns the flowchart', async () => {
flowchartsQuery.queueResult({ data: { id: 'fc-1' }, error: null })

const response = await DELETE(
new Request('http://localhost/api/flowcharts/fc-1', { method: 'DELETE' }),
{ params: Promise.resolve({ id: 'fc-1' }) }
)
const body = await response.json()

expect(response.status).toBe(200)
expect(body).toEqual({ success: true })
expect(flowchartsQuery.eq).toHaveBeenCalledWith('user_id', 'user-1')
})
})
29 changes: 29 additions & 0 deletions __tests__/flowcharts-list-route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { GET } from '@/app/api/flowcharts/route'
import { createQueryMock } from './helpers/supabase-query-mock'

let flowchartsQuery: ReturnType<typeof createQueryMock>

jest.mock('@/lib/rate-limit', () => ({
saveLimit: {},
checkRateLimit: jest.fn(async () => ({ success: true, retryAfter: 0 })),
}))
jest.mock('@/lib/supabase/server', () => ({
createClient: jest.fn(async () => ({
auth: { getUser: jest.fn(async () => ({ data: { user: { id: 'user-1' } }, error: null })) },
from: () => flowchartsQuery,
})),
}))

describe('GET /api/flowcharts', () => {
beforeEach(() => {
flowchartsQuery = createQueryMock()
})

it('caps the list query so a single user cannot load an unbounded number of rows', async () => {
flowchartsQuery.queueResult({ data: [], error: null })

await GET()

expect(flowchartsQuery.limit).toHaveBeenCalledWith(100)
})
})
40 changes: 40 additions & 0 deletions __tests__/flowcharts-patch-route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { PATCH } from '@/app/api/flowcharts/[id]/route'
import { createQueryMock } from './helpers/supabase-query-mock'

let flowchartsQuery: ReturnType<typeof createQueryMock>

jest.mock('@/lib/supabase/server', () => ({
createClient: jest.fn(async () => ({
auth: { getUser: jest.fn(async () => ({ data: { user: { id: 'user-1' } }, error: null })) },
from: () => flowchartsQuery,
})),
}))

function patchRequest(body: object) {
return new Request('http://localhost/api/flowcharts/fc-1', {
method: 'PATCH',
body: JSON.stringify(body),
})
}

describe('PATCH /api/flowcharts/[id]', () => {
beforeEach(() => {
flowchartsQuery = createQueryMock()
})

it('returns 404 when the caller does not own the flowchart', async () => {
flowchartsQuery.queueResult({ data: null, error: { code: 'PGRST116' } })

const response = await PATCH(patchRequest({ title: 'New title' }), { params: Promise.resolve({ id: 'fc-1' }) })

expect(response.status).toBe(404)
})

it('scopes the update to the authenticated user', async () => {
flowchartsQuery.queueResult({ data: { id: 'fc-1', title: 'New title' }, error: null })

await PATCH(patchRequest({ title: 'New title' }), { params: Promise.resolve({ id: 'fc-1' }) })

expect(flowchartsQuery.eq).toHaveBeenCalledWith('user_id', 'user-1')
})
})
57 changes: 57 additions & 0 deletions __tests__/flowcharts-put-route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { PUT } from '@/app/api/flowcharts/[id]/route'
import { createQueryMock } from './helpers/supabase-query-mock'

jest.mock('@/lib/rate-limit', () => ({
saveLimit: {},
checkRateLimit: jest.fn(async () => ({ success: true, retryAfter: 0 })),
}))

let flowchartsQuery: ReturnType<typeof createQueryMock>
let versionsQuery: ReturnType<typeof createQueryMock>

jest.mock('@/lib/supabase/server', () => ({
createClient: jest.fn(async () => ({
auth: { getUser: jest.fn(async () => ({ data: { user: { id: 'user-1' } }, error: null })) },
from: (table: string) => (table === 'flowcharts' ? flowchartsQuery : versionsQuery),
})),
}))

function putRequest(body: object) {
return new Request('http://localhost/api/flowcharts/fc-1', {
method: 'PUT',
body: JSON.stringify(body),
})
}

describe('PUT /api/flowcharts/[id]', () => {
beforeEach(() => {
flowchartsQuery = createQueryMock()
versionsQuery = createQueryMock()
})

it('returns 404 and skips all writes when the caller does not own the flowchart', async () => {
flowchartsQuery.queueResult({ data: null, error: { code: 'PGRST116' } })

const response = await PUT(putRequest({ code: 'x' }), { params: Promise.resolve({ id: 'fc-1' }) })

expect(response.status).toBe(404)
expect(versionsQuery.insert).not.toHaveBeenCalled()
})

it('still returns 200 with the updated flowchart when pruning old versions fails', async () => {
flowchartsQuery
.queueResult({ data: { id: 'fc-1' }, error: null }) // ownership check
.queueResult({ data: { id: 'fc-1', title: 'T' }, error: null }) // final reload

versionsQuery
.queueResult({ data: { version_number: 3 }, error: null }) // latest version lookup
.queueResult({ data: null, error: null }) // version insert
.queueResult({ data: null, error: { message: 'boom' } }) // prune list lookup fails

const response = await PUT(putRequest({ code: 'new code' }), { params: Promise.resolve({ id: 'fc-1' }) })
const body = await response.json()

expect(response.status).toBe(200)
expect(body).toMatchObject({ id: 'fc-1', title: 'T' })
})
})
45 changes: 45 additions & 0 deletions __tests__/flowcharts-share-route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { POST } from '@/app/api/flowcharts/[id]/share/route'
import { createQueryMock } from './helpers/supabase-query-mock'

jest.mock('@/lib/rate-limit', () => ({
shareLimit: {},
checkRateLimit: jest.fn(async () => ({ success: true, retryAfter: 0 })),
}))
jest.mock('@/lib/share', () => ({ generateShareId: () => 'share-xyz' }))

let flowchartsQuery: ReturnType<typeof createQueryMock>

jest.mock('@/lib/supabase/server', () => ({
createClient: jest.fn(async () => ({
auth: { getUser: jest.fn(async () => ({ data: { user: { id: 'user-1' } }, error: null })) },
from: () => flowchartsQuery,
})),
}))

function shareRequest() {
return new Request('http://localhost/api/flowcharts/fc-1/share', { method: 'POST' })
}

describe('POST /api/flowcharts/[id]/share', () => {
beforeEach(() => {
flowchartsQuery = createQueryMock()
})

it('returns 404 when the caller does not own the flowchart', async () => {
flowchartsQuery.queueResult({ data: null, error: { code: 'PGRST116' } })

const response = await POST(shareRequest(), { params: Promise.resolve({ id: 'fc-1' }) })

expect(response.status).toBe(404)
})

it('scopes both the read and the update to the authenticated user', async () => {
flowchartsQuery
.queueResult({ data: { is_public: false, share_id: null }, error: null })
.queueResult({ data: { is_public: true, share_id: 'share-xyz' }, error: null })

await POST(shareRequest(), { params: Promise.resolve({ id: 'fc-1' }) })

expect(flowchartsQuery.eq).toHaveBeenCalledWith('user_id', 'user-1')
})
})
25 changes: 25 additions & 0 deletions __tests__/helpers/supabase-query-mock.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
type QueryResult = { data: any; error: any }

export function createQueryMock() {
const queue: QueryResult[] = []
const mock: any = {
select: jest.fn(() => mock),
eq: jest.fn(() => mock),
order: jest.fn(() => mock),
limit: jest.fn(() => mock),
in: jest.fn(() => mock),
update: jest.fn(() => mock),
insert: jest.fn(() => mock),
delete: jest.fn(() => mock),
single: jest.fn(() => mock),
queueResult(result: QueryResult) {
queue.push(result)
return mock
},
then(resolve: (value: QueryResult) => void) {
resolve(queue.shift() ?? { data: null, error: null })
},
}
return mock
}
1 change: 1 addition & 0 deletions app/(protected)/dashboard/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export default async function DashboardPage() {
.select('*')
.eq('user_id', user!.id)
.order('updated_at', { ascending: false })
.limit(100)

return (
<div className="min-h-screen bg-background">
Expand Down
Loading
Loading