/** * Tests for the API service layer (src/lib/api.ts) * * All HTTP calls are intercepted by mocking global.fetch. * Tests verify correct URL construction, headers, methods, and error handling. */ import { describe, it, expect, vi, afterEach } from 'vitest' import { registerUser, getUserByEmail, updateUserProfile, deleteUser, createEntry, getUserEntries, getEntry, updateEntry, deleteEntry, convertUTCToIST, } from '../lib/api' const TOKEN = 'firebase-id-token' const USER_ID = '507f1f77bcf86cd799439011' const ENTRY_ID = '507f1f77bcf86cd799439022' // --------------------------------------------------------------------------- // Fetch mock helpers // --------------------------------------------------------------------------- function mockFetch(body: unknown, status = 200) { vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: status >= 200 && status < 300, status, statusText: status === 200 ? 'OK' : 'Error', json: () => Promise.resolve(body), })) } function mockFetchError(detail: string, status: number) { vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, status, statusText: 'Error', json: () => Promise.resolve({ detail }), })) } function mockFetchNetworkError() { vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Network error'))) } afterEach(() => { vi.unstubAllGlobals() }) // --------------------------------------------------------------------------- // User Endpoints // --------------------------------------------------------------------------- describe('registerUser', () => { it('sends POST to /users/register', async () => { mockFetch({ id: USER_ID, email: 'a@b.com', message: 'User registered successfully' }) await registerUser({ email: 'a@b.com' }, TOKEN) expect(fetch).toHaveBeenCalledWith( expect.stringContaining('/users/register'), expect.objectContaining({ method: 'POST' }) ) }) it('includes Authorization Bearer token in headers', async () => { mockFetch({}) await registerUser({ email: 'a@b.com' }, TOKEN) expect(fetch).toHaveBeenCalledWith( expect.any(String), expect.objectContaining({ headers: expect.objectContaining({ Authorization: `Bearer ${TOKEN}` }), }) ) }) it('sends displayName and photoURL in body', async () => { mockFetch({}) await registerUser({ email: 'a@b.com', displayName: 'Alice', photoURL: 'https://pic.url' }, TOKEN) const body = JSON.parse((fetch as ReturnType).mock.calls[0][1].body) expect(body).toMatchObject({ email: 'a@b.com', displayName: 'Alice' }) }) it('returns the parsed response', async () => { const response = { id: USER_ID, email: 'a@b.com', message: 'User registered successfully' } mockFetch(response) const result = await registerUser({ email: 'a@b.com' }, TOKEN) expect(result).toEqual(response) }) }) describe('getUserByEmail', () => { it('sends GET to /users/by-email/{email}', async () => { mockFetch({ id: USER_ID, email: 'test@example.com' }) await getUserByEmail('test@example.com', TOKEN) expect(fetch).toHaveBeenCalledWith( expect.stringContaining('/users/by-email/test@example.com'), expect.any(Object) ) }) it('throws "User not found" on 404', async () => { mockFetchError('User not found', 404) await expect(getUserByEmail('ghost@example.com', TOKEN)).rejects.toThrow('User not found') }) }) describe('updateUserProfile', () => { it('sends PUT to /users/{userId}', async () => { mockFetch({ id: USER_ID, theme: 'dark', message: 'User updated successfully' }) await updateUserProfile(USER_ID, { theme: 'dark' }, TOKEN) expect(fetch).toHaveBeenCalledWith( expect.stringContaining(`/users/${USER_ID}`), expect.objectContaining({ method: 'PUT' }) ) }) it('sends only the provided fields', async () => { mockFetch({}) await updateUserProfile(USER_ID, { displayName: 'New Name' }, TOKEN) const body = JSON.parse((fetch as ReturnType).mock.calls[0][1].body) expect(body).toMatchObject({ displayName: 'New Name' }) }) }) describe('deleteUser', () => { it('sends DELETE to /users/{userId}', async () => { mockFetch({ message: 'User deleted successfully', user_deleted: 1, entries_deleted: 3 }) await deleteUser(USER_ID, TOKEN) expect(fetch).toHaveBeenCalledWith( expect.stringContaining(`/users/${USER_ID}`), expect.objectContaining({ method: 'DELETE' }) ) }) it('returns deletion counts', async () => { mockFetch({ message: 'User deleted successfully', user_deleted: 1, entries_deleted: 5 }) const result = await deleteUser(USER_ID, TOKEN) expect(result).toMatchObject({ user_deleted: 1, entries_deleted: 5 }) }) }) // --------------------------------------------------------------------------- // Entry Endpoints // --------------------------------------------------------------------------- describe('createEntry', () => { const encryptedEntry = { encryption: { encrypted: true, ciphertext: 'dGVzdA==', nonce: 'bm9uY2U=', algorithm: 'XSalsa20-Poly1305', }, } it('sends POST to /entries/{userId}', async () => { mockFetch({ id: ENTRY_ID, message: 'Entry created successfully' }) await createEntry(USER_ID, encryptedEntry, TOKEN) expect(fetch).toHaveBeenCalledWith( expect.stringContaining(`/entries/${USER_ID}`), expect.objectContaining({ method: 'POST' }) ) }) it('returns entry id and message', async () => { mockFetch({ id: ENTRY_ID, message: 'Entry created successfully' }) const result = await createEntry(USER_ID, encryptedEntry, TOKEN) expect(result).toMatchObject({ id: ENTRY_ID }) }) it('throws on 404 when user not found', async () => { mockFetchError('User not found', 404) await expect(createEntry('nonexistent-user', encryptedEntry, TOKEN)).rejects.toThrow('User not found') }) }) describe('getUserEntries', () => { it('sends GET to /entries/{userId} with default pagination', async () => { mockFetch({ entries: [], total: 0 }) await getUserEntries(USER_ID, TOKEN) expect(fetch).toHaveBeenCalledWith( expect.stringContaining(`/entries/${USER_ID}?limit=50&skip=0`), expect.any(Object) ) }) it('respects custom limit and skip', async () => { mockFetch({ entries: [], total: 0 }) await getUserEntries(USER_ID, TOKEN, 10, 20) expect(fetch).toHaveBeenCalledWith( expect.stringContaining('limit=10&skip=20'), expect.any(Object) ) }) it('returns entries and total', async () => { mockFetch({ entries: [{ id: ENTRY_ID }], total: 1 }) const result = await getUserEntries(USER_ID, TOKEN) expect(result).toMatchObject({ total: 1 }) }) }) describe('getEntry', () => { it('sends GET to /entries/{userId}/{entryId}', async () => { mockFetch({ id: ENTRY_ID, userId: USER_ID, createdAt: '2024-01-01', updatedAt: '2024-01-01' }) await getEntry(USER_ID, ENTRY_ID, TOKEN) expect(fetch).toHaveBeenCalledWith( expect.stringContaining(`/entries/${USER_ID}/${ENTRY_ID}`), expect.any(Object) ) }) it('throws "Entry not found" on 404', async () => { mockFetchError('Entry not found', 404) await expect(getEntry(USER_ID, 'bad-id', TOKEN)).rejects.toThrow('Entry not found') }) }) describe('updateEntry', () => { it('sends PUT to /entries/{userId}/{entryId}', async () => { mockFetch({ id: ENTRY_ID }) await updateEntry(USER_ID, ENTRY_ID, { mood: 'happy' }, TOKEN) expect(fetch).toHaveBeenCalledWith( expect.stringContaining(`/entries/${USER_ID}/${ENTRY_ID}`), expect.objectContaining({ method: 'PUT' }) ) }) }) describe('deleteEntry', () => { it('sends DELETE to /entries/{userId}/{entryId}', async () => { mockFetch({ message: 'Entry deleted successfully' }) await deleteEntry(USER_ID, ENTRY_ID, TOKEN) expect(fetch).toHaveBeenCalledWith( expect.stringContaining(`/entries/${USER_ID}/${ENTRY_ID}`), expect.objectContaining({ method: 'DELETE' }) ) }) }) describe('convertUTCToIST', () => { it('sends POST to /entries/convert-timestamp/utc-to-ist', async () => { const utc = '2024-01-01T00:00:00Z' mockFetch({ utc, ist: '2024-01-01T05:30:00+05:30' }) await convertUTCToIST(utc) expect(fetch).toHaveBeenCalledWith( expect.stringContaining('/convert-timestamp/utc-to-ist'), expect.objectContaining({ method: 'POST' }) ) }) it('returns both utc and ist fields', async () => { const utc = '2024-01-01T00:00:00Z' mockFetch({ utc, ist: '2024-01-01T05:30:00+05:30' }) const result = await convertUTCToIST(utc) expect(result).toMatchObject({ utc, ist: expect.stringContaining('+05:30') }) }) }) // --------------------------------------------------------------------------- // Generic Error Handling // --------------------------------------------------------------------------- describe('API error handling', () => { it('throws the error detail from response body', async () => { mockFetchError('Specific backend error message', 400) await expect(getUserByEmail('x@x.com', TOKEN)).rejects.toThrow('Specific backend error message') }) it('falls back to "API error: {statusText}" when body has no detail', async () => { vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, status: 500, statusText: 'Internal Server Error', json: () => Promise.reject(new Error('no JSON')), })) await expect(getUserByEmail('x@x.com', TOKEN)).rejects.toThrow('API error: Internal Server Error') }) it('propagates network errors', async () => { mockFetchNetworkError() await expect(getUserByEmail('x@x.com', TOKEN)).rejects.toThrow('Network error') }) it('includes credentials: include in all requests', async () => { mockFetch({}) await getUserByEmail('x@x.com', TOKEN) expect(fetch).toHaveBeenCalledWith( expect.any(String), expect.objectContaining({ credentials: 'include' }) ) }) it('sets Content-Type: application/json on all requests', async () => { mockFetch({}) await getUserByEmail('x@x.com', TOKEN) expect(fetch).toHaveBeenCalledWith( expect.any(String), expect.objectContaining({ headers: expect.objectContaining({ 'Content-Type': 'application/json' }), }) ) }) })