import { test } from 'node:test'; import assert from 'node:assert/strict'; import { fetchDiff } from '../src/lib/review.js'; function giteaStub() { const calls = []; return { calls, getPrDiff: (o, r, i) => { calls.push(['pr', o, r, i]); return Promise.resolve('PRDIFF'); }, getCommitDiff: (o, r, sha) => { calls.push(['commit', o, r, sha]); return Promise.resolve('HEADDIFF'); }, getCompareDiff: (o, r, before, after) => { calls.push(['compare', o, r, before, after]); return Promise.resolve('RANGEDIFF'); } }; } const HEAD = 'a'.repeat(40); const BEFORE = 'b'.repeat(40); test('PR jobs use the PR diff', async () => { const g = giteaStub(); const out = await fetchDiff({ gitea: g, job: { owner: 'o', repo: 'r', prIndex: 5, sha: HEAD, before: null } }); assert.equal(out, 'PRDIFF'); assert.deepEqual(g.calls, [['pr', 'o', 'r', 5]]); }); test('push uses ONE compare diff over the full before...after range', async () => { const g = giteaStub(); const out = await fetchDiff({ gitea: g, job: { owner: 'o', repo: 'r', prIndex: null, sha: HEAD, before: BEFORE, // 25 commits in the payload: must NOT fall back to per-commit slicing commitShas: Array.from({ length: 25 }, (_, i) => String(i).padStart(40, '0')) } }); assert.equal(out, 'RANGEDIFF'); assert.deepEqual(g.calls, [['compare', 'o', 'r', BEFORE, HEAD]]); }); test('new-branch push (before = zero sha) falls back to head commit diff', async () => { const g = giteaStub(); const out = await fetchDiff({ gitea: g, job: { owner: 'o', repo: 'r', prIndex: null, sha: HEAD, before: '0'.repeat(40), commitShas: [HEAD] } }); assert.equal(out, 'HEADDIFF'); assert.deepEqual(g.calls, [['commit', 'o', 'r', HEAD]]); }); test('missing before also falls back to head commit diff', async () => { const g = giteaStub(); const out = await fetchDiff({ gitea: g, job: { owner: 'o', repo: 'r', prIndex: null, sha: HEAD, before: null, commitShas: [HEAD] } }); assert.equal(out, 'HEADDIFF'); assert.deepEqual(g.calls, [['commit', 'o', 'r', HEAD]]); }); test('compare failure propagates (no silent placeholder diff)', async () => { const g = giteaStub(); g.getCompareDiff = () => Promise.reject(new Error('boom 500')); await assert.rejects( () => fetchDiff({ gitea: g, job: { owner: 'o', repo: 'r', prIndex: null, sha: HEAD, before: BEFORE } }), /boom 500/ ); }); // ---- fallback chain (compare 404: force-push, rebase-then-push, or the web // ---- compare route ignoring token auth on private repos) ---- function nf(path) { const e = new Error(`gitea GET ${path} -> 404: Not found.`); e.status = 404; return e; } test('compare 404 falls back to default-branch compare', async () => { const g = giteaStub(); const real = g.getCompareDiff; g.getCompareDiff = (o, r, before, after) => before === BEFORE ? Promise.reject(nf(`/o/r/compare/${before}...${after}.diff`)) : real(o, r, before, after); const out = await fetchDiff({ gitea: g, job: { owner: 'o', repo: 'r', prIndex: null, sha: HEAD, before: BEFORE, defaultBranch: 'main' } }); assert.equal(out, 'RANGEDIFF'); assert.deepEqual(g.calls.at(-1), ['compare', 'o', 'r', 'main', HEAD]); }); test('compare 404 without defaultBranch on the job looks it up via getRepo', async () => { const g = giteaStub(); const real = g.getCompareDiff; g.getCompareDiff = (o, r, before, after) => before === BEFORE ? Promise.reject(nf(`/o/r/compare/${before}...${after}.diff`)) : real(o, r, before, after); g.getRepo = () => Promise.resolve({ default_branch: 'develop' }); const out = await fetchDiff({ gitea: g, job: { owner: 'o', repo: 'r', prIndex: null, sha: HEAD, before: BEFORE } }); assert.equal(out, 'RANGEDIFF'); assert.deepEqual(g.calls.at(-1), ['compare', 'o', 'r', 'develop', HEAD]); }); test('both compares 404 -> API commit list -> concatenated per-commit diffs', async () => { const g = giteaStub(); const C1 = 'c'.repeat(40), C2 = 'd'.repeat(40); g.getCompareDiff = (o, r, before, after) => Promise.reject(nf(`/o/r/compare/${before}...${after}.diff`)); g.compare = (o, r, before, after) => { g.calls.push(['apicompare', o, r, before, after]); return Promise.resolve({ total_commits: 2, commits: [{ sha: C1 }, { sha: C2 }] }); }; g.getCommitDiff = (o, r, sha) => { g.calls.push(['commit', o, r, sha]); return Promise.resolve(`DIFF(${sha.slice(0, 1)})`); }; const out = await fetchDiff({ gitea: g, job: { owner: 'o', repo: 'r', prIndex: null, sha: HEAD, before: BEFORE, defaultBranch: 'main' } }); assert.equal(out, 'DIFF(c)\nDIFF(d)'); assert.deepEqual(g.calls.filter(c => c[0] === 'commit'), [['commit', 'o', 'r', C1], ['commit', 'o', 'r', C2]]); }); test('API compare 404 too -> per-commit diffs from the webhook commitShas', async () => { const g = giteaStub(); const C1 = 'c'.repeat(40); g.getCompareDiff = (o, r, before, after) => Promise.reject(nf(`/o/r/compare/${before}...${after}.diff`)); g.compare = (o, r, before, after) => Promise.reject(nf(`/api/v1/repos/o/r/compare/${before}...${after}`)); g.getCommitDiff = (o, r, sha) => { g.calls.push(['commit', o, r, sha]); return Promise.resolve(`DIFF(${sha.slice(0, 1)})`); }; const out = await fetchDiff({ gitea: g, job: { owner: 'o', repo: 'r', prIndex: null, sha: HEAD, before: BEFORE, defaultBranch: 'main', commitShas: [C1, HEAD] } }); assert.equal(out, 'DIFF(c)\nDIFF(a)'); }); test('per-commit fallback stops fetching past maxBytes (review truncates after)', async () => { const g = giteaStub(); const shas = ['c'.repeat(40), 'd'.repeat(40), 'e'.repeat(40)]; g.getCompareDiff = () => Promise.reject(nf('/o/r/compare/x...y.diff')); g.compare = () => Promise.resolve({ commits: shas.map(sha => ({ sha })) }); const fetched = []; g.getCommitDiff = (o, r, sha) => { fetched.push(sha); return Promise.resolve('x'.repeat(10)); }; const out = await fetchDiff({ gitea: g, job: { owner: 'o', repo: 'r', prIndex: null, sha: HEAD, before: BEFORE, defaultBranch: 'main' }, maxBytes: 15 }); // first two diffs exceed the cap (20 > 15) -> third commit never fetched assert.equal(fetched.length, 2); assert.equal(out, 'x'.repeat(10) + '\n' + 'x'.repeat(10)); }); test('every source 404 -> throws listing every URL tried (then fail-open)', async () => { const g = giteaStub(); g.getCompareDiff = (o, r, before, after) => Promise.reject(nf(`/o/r/compare/${before}...${after}.diff`)); g.compare = (o, r, before, after) => Promise.reject(nf(`/api/v1/repos/o/r/compare/${before}...${after}`)); g.getCommitDiff = (o, r, sha) => Promise.reject(nf(`/api/v1/repos/o/r/git/commits/${sha}.diff`)); await assert.rejects( () => fetchDiff({ gitea: g, job: { owner: 'o', repo: 'r', prIndex: null, sha: HEAD, before: BEFORE, defaultBranch: 'main' } }), (e) => { assert.match(e.message, /^all diff sources failed: /); assert.match(e.message, new RegExp(`compare/${BEFORE}\\.\\.\\.${HEAD}\\.diff`)); assert.match(e.message, new RegExp(`compare/main\\.\\.\\.${HEAD}\\.diff`)); assert.match(e.message, new RegExp(`api/v1/repos/o/r/compare/${BEFORE}`)); assert.match(e.message, new RegExp(`git/commits/${HEAD}\\.diff`)); return true; } ); }); test('PR diff 404 falls back to the head commit diff', async () => { const g = giteaStub(); g.getPrDiff = (o, r, i) => Promise.reject(nf(`/api/v1/repos/o/r/pulls/${i}.diff`)); const out = await fetchDiff({ gitea: g, job: { owner: 'o', repo: 'r', prIndex: 7, sha: HEAD, before: null } }); assert.equal(out, 'HEADDIFF'); assert.deepEqual(g.calls, [['commit', 'o', 'r', HEAD]]); }); test('empty compare body is treated as a miss, not a reviewable diff', async () => { const g = giteaStub(); g.getCompareDiff = (o, r, before) => before === BEFORE ? Promise.resolve('') : Promise.resolve('RANGEDIFF'); const out = await fetchDiff({ gitea: g, job: { owner: 'o', repo: 'r', prIndex: null, sha: HEAD, before: BEFORE, defaultBranch: 'main' } }); assert.equal(out, 'RANGEDIFF'); });