import { describe, it, expect, vi, beforeEach, afterEach, type MockInstance } from 'vitest'; import { register, login, logout, me, createToken, deleteToken } from './auth'; function ok(body: unknown, status = 200): Response { return new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } }); } function noContent(): Response { return new Response(null, { status: 204 }); } function envelope(status: number, code: string, message: string): Response { return new Response(JSON.stringify({ error: { code, message } }), { status, headers: { 'content-type': 'application/json' } }); } const userFixture = { id: 'user-1', username: 'alice', created_at: '2026-01-01T00:00:00Z' }; describe('auth api client', () => { let fetchSpy: MockInstance; beforeEach(() => { fetchSpy = vi.spyOn(globalThis, 'fetch'); }); afterEach(() => { vi.restoreAllMocks(); }); it('register POSTs JSON to /v1/auth/register and returns the user', async () => { fetchSpy.mockResolvedValueOnce(ok({ user: userFixture }, 201)); const user = await register({ username: 'alice', password: 'hunter2hunter2' }); expect(user).toEqual(userFixture); const url = fetchSpy.mock.calls[0][0] as string; expect(url).toMatch(/\/v1\/auth\/register$/); const init = fetchSpy.mock.calls[0][1] as RequestInit; expect(init.method).toBe('POST'); expect(JSON.parse(init.body as string)).toEqual({ username: 'alice', password: 'hunter2hunter2' }); }); it('register surfaces 409 conflict via ApiError.code', async () => { fetchSpy.mockResolvedValueOnce(envelope(409, 'conflict', 'username is already taken')); await expect( register({ username: 'alice', password: 'hunter2hunter2' }) ).rejects.toMatchObject({ status: 409, code: 'conflict' }); }); it('login POSTs JSON to /v1/auth/login and returns the user', async () => { fetchSpy.mockResolvedValueOnce(ok({ user: userFixture })); const user = await login({ username: 'alice', password: 'hunter2hunter2' }); expect(user).toEqual(userFixture); const url = fetchSpy.mock.calls[0][0] as string; expect(url).toMatch(/\/v1\/auth\/login$/); }); it('login surfaces 401 unauthenticated via ApiError.code', async () => { fetchSpy.mockResolvedValueOnce(envelope(401, 'unauthenticated', 'unauthenticated')); await expect( login({ username: 'alice', password: 'wrong' }) ).rejects.toMatchObject({ status: 401, code: 'unauthenticated' }); }); it('logout POSTs to /v1/auth/logout and handles 204', async () => { fetchSpy.mockResolvedValueOnce(noContent()); await expect(logout()).resolves.toBeUndefined(); const url = fetchSpy.mock.calls[0][0] as string; expect(url).toMatch(/\/v1\/auth\/logout$/); const init = fetchSpy.mock.calls[0][1] as RequestInit; expect(init.method).toBe('POST'); }); it('me returns the user on 200', async () => { fetchSpy.mockResolvedValueOnce(ok({ user: userFixture })); await expect(me()).resolves.toEqual(userFixture); }); it('me returns null on 401 (anonymous user)', async () => { fetchSpy.mockResolvedValueOnce(envelope(401, 'unauthenticated', 'unauthenticated')); await expect(me()).resolves.toBeNull(); }); it('me re-throws non-401 errors', async () => { fetchSpy.mockResolvedValueOnce(envelope(500, 'internal_error', 'internal error')); await expect(me()).rejects.toMatchObject({ status: 500 }); }); it('createToken POSTs to /v1/auth/tokens and returns CreatedToken with bearer', async () => { fetchSpy.mockResolvedValueOnce( ok( { id: 't1', user_id: 'user-1', name: 'ci-bot', created_at: '2026-01-01T00:00:00Z', last_used_at: null, bearer: 'raw-token-abc' }, 201 ) ); const t = await createToken('ci-bot'); expect(t.name).toBe('ci-bot'); expect(t.bearer).toBe('raw-token-abc'); const url = fetchSpy.mock.calls[0][0] as string; expect(url).toMatch(/\/v1\/auth\/tokens$/); }); it('deleteToken DELETEs to /v1/auth/tokens/{id} and handles 204', async () => { fetchSpy.mockResolvedValueOnce(noContent()); await expect(deleteToken('t1')).resolves.toBeUndefined(); const url = fetchSpy.mock.calls[0][0] as string; expect(url).toMatch(/\/v1\/auth\/tokens\/t1$/); const init = fetchSpy.mock.calls[0][1] as RequestInit; expect(init.method).toBe('DELETE'); }); });