import { beforeEach, describe, expect, it, vi } from 'vitest'; import { GitHubHost } from '../github'; import { BLOB_THRESHOLD_BYTES } from '../git-host'; const REPO = 'alee/test-vault'; const TOKEN = 'ghp_TEST'; function setupFetch(): ReturnType { const fetchMock = vi.fn(); vi.stubGlobal('fetch', fetchMock); return fetchMock; } describe('GitHubHost.putBlob', () => { beforeEach(() => { vi.unstubAllGlobals(); }); it('uses Contents API when content is at threshold', async () => { const fetchMock = setupFetch(); // First fetch: GET existing file → 404 (create path) fetchMock.mockResolvedValueOnce({ ok: false, status: 404, statusText: 'Not Found' } as Response); // Second fetch: PUT contents → 201 created fetchMock.mockResolvedValueOnce({ ok: true, status: 201, json: async () => ({}) } as Response); const host = new GitHubHost(REPO, TOKEN); const small = new Uint8Array(BLOB_THRESHOLD_BYTES); // exactly threshold await host.putBlob('attachments/abc.bin', small, 'add abc'); expect(fetchMock).toHaveBeenCalledTimes(2); const lastCall = fetchMock.mock.calls[1]; expect(lastCall[0]).toContain('/contents/'); // Contents API expect(lastCall[1]?.method).toBe('PUT'); }); it('uses Git Data API fallback when content exceeds threshold', async () => { const fetchMock = setupFetch(); // 1. POST /git/blobs → blob SHA fetchMock.mockResolvedValueOnce({ ok: true, status: 201, json: async () => ({ sha: 'blobsha111' }) } as Response); // 2. GET /git/refs/heads/main → ref { object: { sha: 'commitsha' } } fetchMock.mockResolvedValueOnce({ ok: true, status: 200, json: async () => ({ object: { sha: 'commitsha111' } }) } as Response); // 3. GET /git/commits/commitsha111 → commit { tree: { sha: 'treesha' } } fetchMock.mockResolvedValueOnce({ ok: true, status: 200, json: async () => ({ tree: { sha: 'treesha111' } }) } as Response); // 4. POST /git/trees → new tree { sha: 'newtreesha' } fetchMock.mockResolvedValueOnce({ ok: true, status: 201, json: async () => ({ sha: 'newtreesha111' }) } as Response); // 5. POST /git/commits → new commit { sha: 'newcommitsha' } fetchMock.mockResolvedValueOnce({ ok: true, status: 201, json: async () => ({ sha: 'newcommitsha111' }) } as Response); // 6. PATCH /git/refs/heads/main → updated fetchMock.mockResolvedValueOnce({ ok: true, status: 200, json: async () => ({}) } as Response); const host = new GitHubHost(REPO, TOKEN); const big = new Uint8Array(BLOB_THRESHOLD_BYTES + 1); // one byte over await host.putBlob('attachments/big.bin', big, 'add big'); expect(fetchMock).toHaveBeenCalledTimes(6); expect(fetchMock.mock.calls[0][0]).toContain('/git/blobs'); expect(fetchMock.mock.calls[1][0]).toContain('/git/refs/heads/'); expect(fetchMock.mock.calls[2][0]).toContain('/git/commits/'); expect(fetchMock.mock.calls[3][0]).toContain('/git/trees'); expect(fetchMock.mock.calls[4][0]).toContain('/git/commits'); expect(fetchMock.mock.calls[5][1]?.method).toBe('PATCH'); }); it('throws when blob creation fails in fallback path', async () => { const fetchMock = setupFetch(); fetchMock.mockResolvedValueOnce({ ok: false, status: 422, statusText: 'Unprocessable', text: async () => 'too big' } as Response); const host = new GitHubHost(REPO, TOKEN); const big = new Uint8Array(BLOB_THRESHOLD_BYTES + 1); await expect(host.putBlob('attachments/big.bin', big, 'add big')).rejects.toThrow(/422|too big/); }); });