import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { SESSION_STORAGE_KEY } from '../src/services/consoleAuth' import { createConsoleClient, FunctionsFetchError, FunctionsHttpError } from '../src/services/consoleClient' import { fakeJwt, memoryStorage } from './console-auth-fixtures' const fetchMock = vi.fn() function client(token: string | null = 'session-jwt') { const storage = memoryStorage() if (token) storage.setItem(SESSION_STORAGE_KEY, JSON.stringify({ access_token: fakeJwt({ sub: 'user-1' }), refresh_token: 'refresh', expires_at: Math.floor(Date.now() / 1000) + 3600, user: { id: 'user-1' } })) vi.stubGlobal('localStorage', storage) return createConsoleClient() } function jsonResponse(body: unknown, status = 200) { return new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } }) } beforeEach(() => { vi.stubEnv('VITE_API_HOST', 'https://api.example.com') vi.stubGlobal('fetch', fetchMock) fetchMock.mockReset() }) afterEach(() => { vi.unstubAllEnvs() vi.unstubAllGlobals() }) describe('console client transport', () => { it('replays a recorded table query through the Capgo API with the session bearer', async () => { fetchMock.mockResolvedValue(jsonResponse({ data: [{ id: 'app-1' }], error: null, count: 1, status: 200 })) const result = await client().from('apps').select('id', { count: 'exact' }).eq('owner_org', 'org-1').order('name').limit(10) expect(result).toEqual({ data: [{ id: 'app-1' }], error: null, count: 1, status: 200 }) const [url, init] = fetchMock.mock.calls[0] expect(url).toBe('https://api.example.com/private/console/query') expect(new Headers(init.headers).get('Authorization')).toBe(`Bearer ${fakeJwt({ sub: 'user-1' })}`) expect(JSON.parse(init.body)).toEqual({ kind: 'table', name: 'apps', args: [], operations: [ { method: 'select', args: ['id', { count: 'exact' }] }, { method: 'eq', args: ['owner_org', 'org-1'] }, { method: 'order', args: ['name'] }, { method: 'limit', args: [10] }, ], }) }) it('drops trailing undefined arguments so server defaults still apply', async () => { fetchMock.mockResolvedValue(jsonResponse({ data: [], error: null, count: null, status: 200 })) await client().from('apps').select('id', undefined as any).order('name', undefined as any).range(0, 9) const [, init] = fetchMock.mock.calls[0] expect(JSON.parse(init.body).operations).toEqual([ { method: 'select', args: ['id'] }, { method: 'order', args: ['name'] }, { method: 'range', args: [0, 9] }, ]) }) it('sends rpc arguments and forwards the abort signal', async () => { fetchMock.mockResolvedValue(jsonResponse({ data: true, error: null, count: null, status: 200 })) const controller = new AbortController() const result = await client().rpc('is_paying_org', { orgid: 'org-1' }).abortSignal(controller.signal) expect(result.data).toBe(true) const [, init] = fetchMock.mock.calls[0] expect(init.signal).toBe(controller.signal) expect(JSON.parse(init.body)).toMatchObject({ kind: 'rpc', name: 'is_paying_org', args: [{ orgid: 'org-1' }] }) }) it('keeps PostgREST error details so callers can match codes', async () => { fetchMock.mockResolvedValue(jsonResponse({ data: null, error: { message: 'duplicate key', code: '23505', details: null, hint: null }, count: null, status: 409 })) const result = await client().from('apps').insert({ app_id: 'dup' } as any) expect(result.data).toBeNull() expect(result.error).toBeInstanceOf(Error) expect(result.error).toMatchObject({ message: 'duplicate key', code: '23505' }) }) it('throws on throwOnError like the SDK', async () => { fetchMock.mockResolvedValue(jsonResponse({ data: null, error: { message: 'denied', code: '42501' }, count: null, status: 403 })) await expect(client().from('apps').select('id').throwOnError()).rejects.toMatchObject({ code: '42501' }) }) it('surfaces API failures as FunctionsHttpError and network failures as FunctionsFetchError', async () => { fetchMock.mockResolvedValueOnce(jsonResponse({ error: 'unsupported_query' }, 403)) const denied = await client().from('secret_table' as any).select('*') expect(denied.error).toBeInstanceOf(FunctionsHttpError) expect(denied.status).toBe(403) fetchMock.mockRejectedValueOnce(new TypeError('Failed to fetch')) const offline = await client().functions.invoke('private/anything', { body: { a: 1 } }) expect(offline.error).toBeInstanceOf(FunctionsFetchError) }) it('retries read queries after a network drop or a 503, never mutations', async () => { fetchMock .mockRejectedValueOnce(new TypeError('Failed to fetch')) .mockResolvedValueOnce(jsonResponse({ error: 'booting' }, 503)) .mockResolvedValueOnce(jsonResponse({ data: [{ id: 'org-1' }], error: null, count: null, status: 200 })) const read = await client().rpc('get_orgs_v7') expect(read.data).toEqual([{ id: 'org-1' }]) expect(fetchMock).toHaveBeenCalledTimes(3) fetchMock.mockReset() fetchMock.mockResolvedValueOnce(jsonResponse({ error: 'booting' }, 503)) const write = await client().from('apps').update({ name: 'x' } as any).eq('id', 'app-1') expect(write.error).toBeInstanceOf(FunctionsHttpError) expect(fetchMock).toHaveBeenCalledTimes(1) // An RPC that mutates is never replayed either, whatever its arguments. fetchMock.mockReset() fetchMock.mockResolvedValueOnce(jsonResponse({ error: 'booting' }, 503)) const rpcWrite = await client().rpc('transfer_app', { p_app_id: 'app-1' } as any) expect(rpcWrite.error).toBeInstanceOf(FunctionsHttpError) expect(fetchMock).toHaveBeenCalledTimes(1) }) it('omits the bearer when there is no session', async () => { fetchMock.mockResolvedValue(jsonResponse({ data: true, error: null, count: null, status: 200 })) await client(null).rpc('is_not_deleted', { email_check: 'a@b.c' }) const [, init] = fetchMock.mock.calls[0] expect(new Headers(init.headers).has('Authorization')).toBe(false) }) it('routes storage calls to the image endpoints with raw bytes for uploads', async () => { fetchMock.mockResolvedValueOnce(jsonResponse({ data: { signedUrl: 'https://api.example.com/private/console/images/read?path=a&token=t' }, error: null })) const signed = await client().storage.from('images').createSignedUrl('org/logo.png', 60) expect(signed.data?.signedUrl).toContain('/private/console/images/read') expect(fetchMock.mock.calls[0][0]).toBe('https://api.example.com/private/console/images/sign') fetchMock.mockResolvedValueOnce(jsonResponse({ data: { path: 'org/logo.png' }, error: null })) const blob = new Blob(['png-bytes'], { type: 'image/png' }) await client().storage.from('images').upload('org/logo.png', blob, { contentType: 'image/png', upsert: true }) const [url, init] = fetchMock.mock.calls[1] expect(url).toBe('https://api.example.com/private/console/images/upload') expect(init.body).toBe(blob) const headers = new Headers(init.headers) expect(headers.get('x-image-path')).toBe('org/logo.png') expect(headers.get('x-image-upsert')).toBe('true') expect(headers.get('Content-Type')).toBe('image/png') expect(() => client().storage.from('other')).toThrow('Unsupported console storage bucket') }) })