/** * Unit tests for GitHub Compare API client (TRUEREF-0021). */ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { fetchGitHubChangedFiles } from './github-compare.js'; import { GitHubApiError } from './github-tags.js'; // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- function mockFetch(status: number, body: unknown): void { vi.spyOn(global, 'fetch').mockResolvedValueOnce( new Response(JSON.stringify(body), { status }) ); } beforeEach(() => { vi.restoreAllMocks(); }); // --------------------------------------------------------------------------- // fetchGitHubChangedFiles // --------------------------------------------------------------------------- describe('fetchGitHubChangedFiles', () => { it('maps added status correctly', async () => { mockFetch(200, { status: 'ahead', files: [{ filename: 'src/new.ts', status: 'added', sha: 'abc123' }] }); const result = await fetchGitHubChangedFiles('owner', 'repo', 'v1.0.0', 'v1.1.0'); expect(result).toHaveLength(1); expect(result[0]).toMatchObject({ path: 'src/new.ts', status: 'added', sha: 'abc123' }); }); it('maps modified status correctly', async () => { mockFetch(200, { status: 'ahead', files: [{ filename: 'src/index.ts', status: 'modified', sha: 'def456' }] }); const result = await fetchGitHubChangedFiles('owner', 'repo', 'v1.0.0', 'v1.1.0'); expect(result[0]).toMatchObject({ path: 'src/index.ts', status: 'modified' }); }); it('maps removed status correctly and omits sha', async () => { mockFetch(200, { status: 'ahead', files: [{ filename: 'src/old.ts', status: 'removed', sha: '000000' }] }); const result = await fetchGitHubChangedFiles('owner', 'repo', 'v1.0.0', 'v1.1.0'); expect(result[0]).toMatchObject({ path: 'src/old.ts', status: 'removed' }); expect(result[0].sha).toBeUndefined(); }); it('maps renamed status and sets previousPath', async () => { mockFetch(200, { status: 'ahead', files: [ { filename: 'src/renamed.ts', status: 'renamed', sha: 'ghi789', previous_filename: 'src/original.ts' } ] }); const result = await fetchGitHubChangedFiles('owner', 'repo', 'v1.0.0', 'v1.1.0'); expect(result[0]).toMatchObject({ path: 'src/renamed.ts', status: 'renamed', previousPath: 'src/original.ts', sha: 'ghi789' }); }); it('returns empty array when compare status is identical', async () => { mockFetch(200, { status: 'identical', files: [] }); const result = await fetchGitHubChangedFiles('owner', 'repo', 'v1.0.0', 'v1.0.0'); expect(result).toEqual([]); }); it('returns empty array when compare status is behind', async () => { mockFetch(200, { status: 'behind', files: [{ filename: 'src/index.ts', status: 'modified', sha: 'abc' }] }); const result = await fetchGitHubChangedFiles('owner', 'repo', 'v1.1.0', 'v1.0.0'); expect(result).toEqual([]); }); it('throws GitHubApiError on 401 unauthorized', async () => { mockFetch(401, { message: 'Unauthorized' }); await expect( fetchGitHubChangedFiles('owner', 'private-repo', 'v1.0.0', 'v1.1.0') ).rejects.toThrow(GitHubApiError); }); it('throws GitHubApiError on 404 not found', async () => { mockFetch(404, { message: 'Not Found' }); await expect( fetchGitHubChangedFiles('owner', 'missing-repo', 'v1.0.0', 'v1.1.0') ).rejects.toThrow(GitHubApiError); }); it('throws GitHubApiError on 422 unprocessable entity', async () => { mockFetch(422, { message: 'Unprocessable Entity' }); await expect( fetchGitHubChangedFiles('owner', 'repo', 'bad-ref', 'v1.1.0') ).rejects.toThrow(GitHubApiError); }); it('returns empty array when files property is missing', async () => { mockFetch(200, { status: 'ahead' }); const result = await fetchGitHubChangedFiles('owner', 'repo', 'v1.0.0', 'v1.1.0'); expect(result).toEqual([]); }); it('returns empty array when files array is empty', async () => { mockFetch(200, { status: 'ahead', files: [] }); const result = await fetchGitHubChangedFiles('owner', 'repo', 'v1.0.0', 'v1.1.0'); expect(result).toEqual([]); }); it('maps copied status to modified', async () => { mockFetch(200, { status: 'ahead', files: [{ filename: 'src/copy.ts', status: 'copied', sha: 'jkl012' }] }); const result = await fetchGitHubChangedFiles('owner', 'repo', 'v1.0.0', 'v1.1.0'); expect(result[0]).toMatchObject({ path: 'src/copy.ts', status: 'modified' }); }); it('maps changed status to modified', async () => { mockFetch(200, { status: 'ahead', files: [{ filename: 'src/changed.ts', status: 'changed', sha: 'mno345' }] }); const result = await fetchGitHubChangedFiles('owner', 'repo', 'v1.0.0', 'v1.1.0'); expect(result[0]).toMatchObject({ path: 'src/changed.ts', status: 'modified' }); }); it('sends Authorization header when token is provided', async () => { const fetchSpy = vi.spyOn(global, 'fetch').mockResolvedValueOnce( new Response(JSON.stringify({ status: 'ahead', files: [] }), { status: 200 }) ); await fetchGitHubChangedFiles('owner', 'repo', 'v1.0.0', 'v1.1.0', 'my-token'); const callArgs = fetchSpy.mock.calls[0]; const headers = (callArgs[1] as RequestInit).headers as Record; expect(headers['Authorization']).toBe('Bearer my-token'); }); it('does not send Authorization header when no token provided', async () => { const fetchSpy = vi.spyOn(global, 'fetch').mockResolvedValueOnce( new Response(JSON.stringify({ status: 'ahead', files: [] }), { status: 200 }) ); await fetchGitHubChangedFiles('owner', 'repo', 'v1.0.0', 'v1.1.0'); const callArgs = fetchSpy.mock.calls[0]; const headers = (callArgs[1] as RequestInit).headers as Record; expect(headers['Authorization']).toBeUndefined(); }); it('throws GitHubApiError with correct status code', async () => { mockFetch(403, { message: 'Forbidden' }); try { await fetchGitHubChangedFiles('owner', 'repo', 'v1.0.0', 'v1.1.0'); expect.fail('should have thrown'); } catch (e) { expect(e).toBeInstanceOf(GitHubApiError); expect((e as GitHubApiError).status).toBe(403); } }); });