import { beforeEach, describe, expect, it, vi } from 'vitest' import { BaseCrudApi } from './BaseCrudApi' import type { ApiClient } from './ApiClient' import type { Paginated } from './types' // ── Test types ─────────────────────────────────────────────────────── type Item = { id: string; name: string } type CreateItem = { name: string } type UpdateItem = { name?: string } // ── Mock client factory ────────────────────────────────────────────── function createMockClient(): ApiClient { return { setAccessToken: vi.fn().mockReturnThis(), request: vi.fn(), get: vi.fn(), post: vi.fn(), put: vi.fn(), patch: vi.fn(), delete: vi.fn(), } } // ── Tests ──────────────────────────────────────────────────────────── describe('BaseCrudApi', () => { let client: ReturnType let api: BaseCrudApi beforeEach(() => { client = createMockClient() api = new BaseCrudApi(client, '/items') }) // ── Constructor ────────────────────────────────────────────────── describe('constructor', () => { it('strips trailing slash from basePath', () => { const api2 = new BaseCrudApi(client, '/items/') const mockResponse: Paginated = { data: [], total: 0, limit: 10, skip: 0, currentPage: 1, lastPage: 1, from: 0, to: 0, } vi.mocked(client.get).mockResolvedValue(mockResponse) api2.getMany() expect(client.get).toHaveBeenCalledWith('/items', undefined) }) it('works with basePath without trailing slash', () => { const mockResponse: Paginated = { data: [], total: 0, limit: 10, skip: 0, currentPage: 1, lastPage: 1, from: 0, to: 0, } vi.mocked(client.get).mockResolvedValue(mockResponse) api.getMany() expect(client.get).toHaveBeenCalledWith('/items', undefined) }) }) // ── getMany ────────────────────────────────────────────────────── describe('getMany', () => { it('calls client.get with basePath when no query is provided', async () => { const mockResponse: Paginated = { data: [{ id: '1', name: 'Test' }], total: 1, limit: 10, skip: 0, currentPage: 1, lastPage: 1, from: 1, to: 1, } vi.mocked(client.get).mockResolvedValue(mockResponse) const result = await api.getMany() expect(client.get).toHaveBeenCalledWith('/items', undefined) expect(result).toEqual(mockResponse) }) it('serializes query parameters into query string', async () => { vi.mocked(client.get).mockResolvedValue({ data: [], total: 0 }) await api.getMany({ page: 2, perPage: 25 }) const calledUrl = vi.mocked(client.get).mock.calls.at(0)?.at(0) as string expect(calledUrl).toContain('/items?') expect(calledUrl).toContain('page=2') expect(calledUrl).toContain('perPage=25') }) it('serializes where clause into query string', async () => { vi.mocked(client.get).mockResolvedValue({ data: [], total: 0 }) await api.getMany({ where: { name: 'test' } }) const calledUrl = vi.mocked(client.get).mock.calls.at(0)?.at(0) as string expect(calledUrl).toContain('where') expect(calledUrl).toContain('name') expect(calledUrl).toContain('test') }) it('forwards custom headers', async () => { vi.mocked(client.get).mockResolvedValue({ data: [], total: 0 }) const headers = { 'X-Custom': 'value' } await api.getMany(undefined, headers) expect(client.get).toHaveBeenCalledWith('/items', headers) }) it('forwards headers with query', async () => { vi.mocked(client.get).mockResolvedValue({ data: [], total: 0 }) const headers = { Authorization: 'Bearer token' } await api.getMany({ page: 1 }, headers) expect(vi.mocked(client.get).mock.calls.at(0)?.at(1)).toEqual(headers) }) }) // ── getOne ─────────────────────────────────────────────────────── describe('getOne', () => { it('requests limit 1 (non-paginated) and returns the first item', async () => { const item: Item = { id: '1', name: 'Test' } vi.mocked(client.get).mockResolvedValue([item]) const result = await api.getOne() const calledUrl = vi.mocked(client.get).mock.calls.at(0)?.at(0) as string expect(calledUrl).toContain('/items?') expect(calledUrl).toContain('limit=1') expect(calledUrl).toContain('paginate=false') expect(result).toEqual(item) }) it('returns null when no records match', async () => { vi.mocked(client.get).mockResolvedValue([]) const result = await api.getOne({ where: { name: 'missing' } }) expect(result).toBeNull() }) it('forwards custom headers', async () => { vi.mocked(client.get).mockResolvedValue([]) const headers = { 'X-Custom': 'value' } await api.getOne(undefined, headers) expect(vi.mocked(client.get).mock.calls.at(0)?.at(1)).toEqual(headers) }) }) // ── getById ────────────────────────────────────────────────────── describe('getById', () => { it('calls client.get with basePath/id', async () => { const item: Item = { id: 'abc', name: 'Test' } vi.mocked(client.get).mockResolvedValue(item) const result = await api.getById('abc') expect(client.get).toHaveBeenCalledWith('/items/abc', undefined) expect(result).toEqual(item) }) it('forwards custom headers', async () => { vi.mocked(client.get).mockResolvedValue({ id: '1', name: 'x' }) const headers = { 'X-Tenant': '123' } await api.getById('1', undefined, headers) expect(client.get).toHaveBeenCalledWith('/items/1', headers) }) }) // ── create ─────────────────────────────────────────────────────── describe('create', () => { it('calls client.post with basePath and body', async () => { const created: Item = { id: 'new', name: 'New Item' } vi.mocked(client.post).mockResolvedValue(created) const result = await api.create({ name: 'New Item' }) expect(client.post).toHaveBeenCalledWith('/items', { name: 'New Item' }, undefined) expect(result).toEqual(created) }) it('forwards custom headers', async () => { vi.mocked(client.post).mockResolvedValue({ id: '1', name: 'x' }) const headers = { 'X-Request-Id': 'req-1' } await api.create({ name: 'x' }, headers) expect(client.post).toHaveBeenCalledWith('/items', { name: 'x' }, headers) }) }) // ── updateById ─────────────────────────────────────────────────── describe('updateById', () => { it('calls client.put with basePath/id and body', async () => { const updated: Item = { id: 'abc', name: 'Updated' } vi.mocked(client.put).mockResolvedValue(updated) const result = await api.updateById('abc', { name: 'Updated' }) expect(client.put).toHaveBeenCalledWith('/items/abc', { name: 'Updated' }, undefined) expect(result).toEqual(updated) }) it('forwards custom headers', async () => { vi.mocked(client.put).mockResolvedValue({ id: '1', name: 'y' }) const headers = { 'If-Match': 'etag-1' } await api.updateById('1', { name: 'y' }, headers) expect(client.put).toHaveBeenCalledWith('/items/1', { name: 'y' }, headers) }) }) // ── deleteById ─────────────────────────────────────────────────── describe('deleteById', () => { it('calls client.delete with basePath/id', async () => { const deleted: Item = { id: 'abc', name: 'Deleted' } vi.mocked(client.delete).mockResolvedValue(deleted) const result = await api.deleteById('abc') expect(client.delete).toHaveBeenCalledWith('/items/abc', undefined) expect(result).toEqual(deleted) }) it('forwards custom headers', async () => { vi.mocked(client.delete).mockResolvedValue({ id: '1', name: 'z' }) const headers = { 'X-Reason': 'cleanup' } await api.deleteById('1', headers) expect(client.delete).toHaveBeenCalledWith('/items/1', headers) }) }) // ── Error propagation ──────────────────────────────────────────── describe('error propagation', () => { it('propagates errors from client.get in getMany', async () => { vi.mocked(client.get).mockRejectedValue(new Error('Network error')) await expect(api.getMany()).rejects.toThrow('Network error') }) it('propagates errors from client.get in getById', async () => { vi.mocked(client.get).mockRejectedValue(new Error('Not found')) await expect(api.getById('missing')).rejects.toThrow('Not found') }) it('propagates errors from client.post in create', async () => { vi.mocked(client.post).mockRejectedValue(new Error('Validation failed')) await expect(api.create({ name: 'bad' })).rejects.toThrow('Validation failed') }) it('propagates errors from client.put in updateById', async () => { vi.mocked(client.put).mockRejectedValue(new Error('Conflict')) await expect(api.updateById('1', { name: 'x' })).rejects.toThrow('Conflict') }) it('propagates errors from client.delete in deleteById', async () => { vi.mocked(client.delete).mockRejectedValue(new Error('Forbidden')) await expect(api.deleteById('1')).rejects.toThrow('Forbidden') }) }) // ── Query serialization edge cases ─────────────────────────────── describe('query serialization', () => { it('handles complex where with operators', async () => { vi.mocked(client.get).mockResolvedValue({ data: [], total: 0 }) await api.getMany({ where: { name: { $like: '%test%' } } }) const calledUrl = vi.mocked(client.get).mock.calls.at(0)?.at(0) as string expect(calledUrl).toMatch(/^\/items\?/) expect(calledUrl).toContain('%25test%25') }) it('handles order parameter', async () => { vi.mocked(client.get).mockResolvedValue({ data: [], total: 0 }) await api.getMany({ order: [['name', 'ASC']] }) const calledUrl = vi.mocked(client.get).mock.calls.at(0)?.at(0) as string expect(calledUrl).toContain('order') expect(calledUrl).toContain('name') expect(calledUrl).toContain('ASC') }) it('handles empty query object', async () => { vi.mocked(client.get).mockResolvedValue({ data: [], total: 0 }) await api.getMany({}) const calledUrl = vi.mocked(client.get).mock.calls.at(0)?.at(0) as string // qs.stringify({}) produces '', so we get '/items?' expect(calledUrl).toBe('/items?') }) }) })