import { afterEach, describe, expect, it, vi } from 'vitest'; import { ApiClientError, approveSubmission, listAdminSubmissions, listMySubmissions, needsChangesSubmission, promoteLink, rejectSubmission, } from '../src/lib/api'; const originalFetch = globalThis.fetch; function mockFetch(responder: (input: RequestInfo | URL, init?: RequestInit) => { status?: number; body?: unknown; statusText?: string; }) { const calls: { path: string; init?: RequestInit }[] = []; const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { const path = typeof input === 'string' ? input : new URL(input.toString()).pathname; calls.push({ path, init }); const { status = 200, body = {}, statusText = 'OK' } = responder(input, init); return new Response(JSON.stringify(body), { status, statusText, headers: { 'content-type': 'application/json' }, }); }); globalThis.fetch = fetchMock as unknown as typeof fetch; return { calls, fetchMock }; } afterEach(() => { globalThis.fetch = originalFetch; }); describe('promotion api client', () => { it('promoteLink POSTs privateLinkId, proposedAlias, note to /api/promotions', async () => { const { calls } = mockFetch((_input, init) => ({ status: 201, body: { submission: { id: 'sub_1', status: 'pending', proposedAlias: 'docs' } }, })); const result = await promoteLink({ privateLinkId: 'priv_1', proposedAlias: 'docs', note: 'please review', }); expect(calls[0].path).toBe('/api/promotions'); expect(calls[0].init?.method).toBe('POST'); expect(calls[0].init?.credentials).toBe('include'); const body = JSON.parse(calls[0].init?.body as string); expect(body).toEqual({ privateLinkId: 'priv_1', proposedAlias: 'docs', note: 'please review' }); expect(result.submission.id).toBe('sub_1'); }); it('promoteLink omits the note field when it is empty or undefined', async () => { const { calls } = mockFetch(() => ({ status: 201, body: { submission: { id: 'sub_2', status: 'pending' } }, })); await promoteLink({ privateLinkId: 'priv_1', proposedAlias: 'docs', note: '' }); const body = JSON.parse(calls[0].init?.body as string); expect(body).not.toHaveProperty('note'); }); it('listMySubmissions GETs /api/promotions/mine with credentials', async () => { const { calls } = mockFetch(() => ({ body: { submissions: [{ id: 'sub_1', status: 'pending', proposedAlias: 'docs' }] }, })); const result = await listMySubmissions(); expect(calls[0].path).toBe('/api/promotions/mine'); expect(calls[0].init?.method).toBeUndefined(); expect(calls[0].init?.credentials).toBe('include'); expect(result.submissions).toHaveLength(1); }); it('listAdminSubmissions encodes the status filter into the query string', async () => { const { calls } = mockFetch(() => ({ body: { submissions: [] } })); await listAdminSubmissions('needs_changes'); expect(calls[0].path).toBe('/api/admin/promotions?status=needs_changes'); }); it('approveSubmission POSTs to /api/admin/promotions/:id/approve and returns publicLink', async () => { const { calls } = mockFetch(() => ({ body: { submission: { id: 'sub_1', status: 'approved' }, publicLink: { id: 'pub_1', alias: 'docs', scope: 'public' }, }, })); const result = await approveSubmission('sub_1', { reason: 'lgtm' }); expect(calls[0].path).toBe('/api/admin/promotions/sub_1/approve'); expect(calls[0].init?.method).toBe('POST'); const body = JSON.parse(calls[0].init?.body as string); expect(body).toEqual({ reason: 'lgtm' }); expect(result.publicLink.id).toBe('pub_1'); }); it('rejectSubmission posts an empty object when no reason is given', async () => { const { calls } = mockFetch(() => ({ body: { submission: { id: 'sub_1', status: 'rejected', rejectionReason: null } }, })); await rejectSubmission('sub_1'); expect(calls[0].path).toBe('/api/admin/promotions/sub_1/reject'); const body = JSON.parse(calls[0].init?.body as string); expect(body).toEqual({}); }); it('needsChangesSubmission targets the needs-changes endpoint', async () => { const { calls } = mockFetch(() => ({ body: { submission: { id: 'sub_1', status: 'needs_changes' } }, })); await needsChangesSubmission('sub_1', { reason: 'tighten description' }); expect(calls[0].path).toBe('/api/admin/promotions/sub_1/needs-changes'); const body = JSON.parse(calls[0].init?.body as string); expect(body).toEqual({ reason: 'tighten description' }); }); it('encodes submission ids with special characters in review paths', async () => { const { calls } = mockFetch(() => ({ body: { submission: { id: 'a/b', status: 'approved' } } })); await approveSubmission('a/b'); expect(calls[0].path).toBe('/api/admin/promotions/a%2Fb/approve'); }); it('surfaces server errors as ApiClientError', async () => { mockFetch(() => ({ status: 409, body: { error: 'A pending submission for this link and alias already exists' } })); await expect(promoteLink({ privateLinkId: 'priv_1', proposedAlias: 'docs' })) .rejects.toMatchObject({ name: 'ApiClientError', status: 409, message: 'A pending submission for this link and alias already exists', }); }); });