Files
trueref/src/lib/server/crawler/github-compare.test.ts

174 lines
6.1 KiB
TypeScript

/**
* 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<string, string>;
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<string, string>;
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);
}
});
});