const { test, afterEach } = require('node:test'); const assert = require('node:assert/strict'); const { ForgejoClient } = require('../src/api'); const pkg = require('../package.json'); const realFetch = global.fetch; afterEach(() => { global.fetch = realFetch; }); function mockFetch(handler) { const calls = []; global.fetch = async (url, opts) => { calls.push({ url, opts }); return handler(url, opts, calls.length); }; return calls; } function jsonResponse(body, status = 200) { return { ok: status >= 200 && status < 300, status, text: async () => JSON.stringify(body), }; } test('fromConfig rejects missing credentials with a stoke-branded hint', () => { assert.throws(() => ForgejoClient.fromConfig(null), /stoke auth login/); assert.throws(() => ForgejoClient.fromConfig({ url: 'https://x' }), /stoke auth login/); }); test('trailing slash in base URL is normalized', async () => { const calls = mockFetch(() => jsonResponse({ ok: true })); const client = new ForgejoClient('https://forge.test/', 'tok'); await client.get('/user'); assert.equal(calls[0].url, 'https://forge.test/api/v1/user'); }); test('token auth wins and User-Agent matches the package', async () => { const calls = mockFetch(() => jsonResponse({})); const client = new ForgejoClient('https://forge.test', 'tok'); await client.get('/user'); const headers = calls[0].opts.headers; assert.equal(headers.Authorization, 'token tok'); assert.equal(headers['User-Agent'], `stoke/${pkg.version}`); }); test('withBasicAuth sends Basic credentials and drops the token', async () => { const calls = mockFetch(() => jsonResponse({})); const client = new ForgejoClient('https://forge.test', 'tok').withBasicAuth('user', 'pass'); await client.get('/user'); const expected = `Basic ${Buffer.from('user:pass').toString('base64')}`; assert.equal(calls[0].opts.headers.Authorization, expected); }); test('deleteToken uses Basic auth (Forgejo rejects token auth on token endpoints)', async () => { const calls = mockFetch(() => jsonResponse(null, 204)); const client = new ForgejoClient('https://forge.test', 'tok'); await client.deleteToken('user@example.test', 'pass', 'user', 42); const { url, opts } = calls[0]; assert.equal(url, 'https://forge.test/api/v1/users/user/tokens/42'); assert.equal(opts.method, 'DELETE'); assert.match(opts.headers.Authorization, /^Basic /); }); test('API errors carry message, status and body', async () => { mockFetch(() => jsonResponse({ message: 'user does not exist', url: 'https://forge.test/api/swagger' }, 404)); const client = new ForgejoClient('https://forge.test', 'tok'); await assert.rejects(() => client.get('/users/ghost'), (err) => { assert.equal(err.message, 'user does not exist'); assert.equal(err.status, 404); assert.equal(err.body.url, 'https://forge.test/api/swagger'); return true; }); }); test('non-JSON error bodies are surfaced raw', async () => { mockFetch(() => ({ ok: false, status: 502, text: async () => 'Bad Gateway' })); const client = new ForgejoClient('https://forge.test', 'tok'); await assert.rejects(() => client.get('/user'), /Bad Gateway/); }); test('network failures are wrapped with the base URL', async () => { global.fetch = async () => { throw new Error('ECONNREFUSED'); }; const client = new ForgejoClient('https://forge.test', 'tok'); await assert.rejects(() => client.get('/user'), /Network error reaching https:\/\/forge\.test/); }); test('getAll paginates until a short page', async () => { const pageOf = (n, count) => Array.from({ length: count }, (_, i) => ({ id: (n - 1) * 50 + i })); mockFetch((url) => { const page = Number(new URL(url).searchParams.get('page')); if (page === 1) return jsonResponse(pageOf(1, 50)); if (page === 2) return jsonResponse(pageOf(2, 3)); throw new Error('should not fetch beyond a short page'); }); const client = new ForgejoClient('https://forge.test', 'tok'); const all = await client.getAll('/user/repos'); assert.equal(all.length, 53); }); test('getAll stops on an empty first page', async () => { const calls = mockFetch(() => jsonResponse([])); const client = new ForgejoClient('https://forge.test', 'tok'); const all = await client.getAll('/user/repos'); assert.deepEqual(all, []); assert.equal(calls.length, 1); }); test('searchUsers unwraps the {data: []} envelope and paginates', async () => { mockFetch((url) => { const page = Number(new URL(url).searchParams.get('page')); if (page === 1) return jsonResponse({ data: Array.from({ length: 50 }, (_, i) => ({ login: `u${i}` })) }); return jsonResponse({ data: [{ login: 'last' }] }); }); const client = new ForgejoClient('https://forge.test', 'tok'); const users = await client.searchUsers('u'); assert.equal(users.length, 51); assert.equal(users.at(-1).login, 'last'); }); test('mergePullRequest posts the merge payload to the merge endpoint', async () => { const calls = mockFetch(() => jsonResponse(null, 200)); const client = new ForgejoClient('https://forge.test', 'tok'); await client.mergePullRequest('owner', 'repo', 7, { Do: 'squash', delete_branch_after_merge: true }); assert.equal(calls[0].url, 'https://forge.test/api/v1/repos/owner/repo/pulls/7/merge'); assert.equal(calls[0].opts.method, 'POST'); assert.deepEqual(JSON.parse(calls[0].opts.body), { Do: 'squash', delete_branch_after_merge: true }); }); test('createIssue and createPullRequest hit the expected endpoints', async () => { const calls = mockFetch(() => jsonResponse({ number: 1 })); const client = new ForgejoClient('https://forge.test', 'tok'); await client.createIssue('own/er', 'repo', { title: 't' }); await client.createPullRequest('owner', 're po', { title: 't', head: 'h', base: 'b' }); assert.equal(calls[0].url, 'https://forge.test/api/v1/repos/own%2Fer/repo/issues'); assert.equal(calls[1].url, 'https://forge.test/api/v1/repos/owner/re%20po/pulls'); assert.equal(calls[0].opts.method, 'POST'); assert.equal(JSON.parse(calls[1].opts.body).head, 'h'); }); test('getPullRequest fetches a single pull request', async () => { const calls = mockFetch(() => jsonResponse({ number: 7, title: 'Fix' })); const client = new ForgejoClient('https://forge.test', 'tok'); const pr = await client.getPullRequest('owner', 'repo', 7); assert.equal(pr.number, 7); assert.equal(calls[0].url, 'https://forge.test/api/v1/repos/owner/repo/pulls/7'); assert.equal(calls[0].opts.method, 'GET'); }); test('createPullRequestComment posts to the issue comments endpoint', async () => { const calls = mockFetch(() => jsonResponse({ id: 99, html_url: 'https://forge.test/comment/99' })); const client = new ForgejoClient('https://forge.test', 'tok'); await client.createPullRequestComment('owner', 'repo', 7, 'Looks good.'); assert.equal(calls[0].url, 'https://forge.test/api/v1/repos/owner/repo/issues/7/comments'); assert.equal(calls[0].opts.method, 'POST'); assert.equal(JSON.parse(calls[0].opts.body).body, 'Looks good.'); }); test('createPullRequestReview posts the review event and body', async () => { const calls = mockFetch(() => jsonResponse({ id: 88 })); const client = new ForgejoClient('https://forge.test', 'tok'); await client.createPullRequestReview('owner', 'repo', 7, 'APPROVED', 'Ship it.'); assert.equal(calls[0].url, 'https://forge.test/api/v1/repos/owner/repo/pulls/7/reviews'); assert.equal(calls[0].opts.method, 'POST'); const body = JSON.parse(calls[0].opts.body); assert.equal(body.event, 'APPROVED'); assert.equal(body.body, 'Ship it.'); }); test('createPullRequestReview preserves leading and trailing whitespace in the body', async () => { const calls = mockFetch(() => jsonResponse({ id: 89 })); const client = new ForgejoClient('https://forge.test', 'tok'); const rawBody = ' code block prefix\n'; await client.createPullRequestReview('owner', 'repo', 8, 'REQUEST_CHANGES', rawBody); const body = JSON.parse(calls[0].opts.body); assert.equal(body.body, rawBody); }); test('getAll joins pagination with & when the endpoint already has a query', async () => { const calls = mockFetch(() => jsonResponse([])); const client = new ForgejoClient('https://forge.test', 'tok'); await client.getAll('/repos/o/r/pulls?state=closed'); assert.equal(calls[0].url, 'https://forge.test/api/v1/repos/o/r/pulls?state=closed&limit=50&page=1'); }); test('release endpoints map to the expected URLs and payloads', async () => { const calls = mockFetch(() => jsonResponse({ tag_name: '1.0.0' })); const client = new ForgejoClient('https://forge.test', 'tok'); await client.listReleases('owner', 'repo'); await client.getReleaseByTag('owner', 'repo', '1.0.0-rc1'); await client.createRelease('owner', 'repo', { tag_name: '1.0.0', name: '1.0.0', body: '', draft: false, prerelease: false }); assert.equal(calls[0].url, 'https://forge.test/api/v1/repos/owner/repo/releases?limit=50&page=1'); assert.equal(calls[1].url, 'https://forge.test/api/v1/repos/owner/repo/releases/tags/1.0.0-rc1'); assert.equal(calls[2].url, 'https://forge.test/api/v1/repos/owner/repo/releases'); assert.equal(calls[2].opts.method, 'POST'); assert.equal(JSON.parse(calls[2].opts.body).tag_name, '1.0.0'); }); test('label endpoints map to the expected URLs and payloads', async () => { const calls = mockFetch(() => jsonResponse({ id: 3 })); const client = new ForgejoClient('https://forge.test', 'tok'); await client.createLabel('owner', 'repo', { name: 'release', color: '0e8a16', description: '' }); await client.deleteLabel('owner', 'repo', 3); assert.equal(calls[0].url, 'https://forge.test/api/v1/repos/owner/repo/labels'); assert.equal(calls[0].opts.method, 'POST'); assert.deepEqual(JSON.parse(calls[0].opts.body), { name: 'release', color: '0e8a16', description: '' }); assert.equal(calls[1].url, 'https://forge.test/api/v1/repos/owner/repo/labels/3'); assert.equal(calls[1].opts.method, 'DELETE'); }); test('issue label add/remove hit the issue labels endpoints', async () => { const calls = mockFetch(() => jsonResponse(null, 204)); const client = new ForgejoClient('https://forge.test', 'tok'); await client.addIssueLabels('owner', 'repo', 7, [3, 4]); await client.removeIssueLabel('owner', 'repo', 7, 3); assert.equal(calls[0].url, 'https://forge.test/api/v1/repos/owner/repo/issues/7/labels'); assert.equal(calls[0].opts.method, 'POST'); assert.deepEqual(JSON.parse(calls[0].opts.body), { labels: [3, 4] }); assert.equal(calls[1].url, 'https://forge.test/api/v1/repos/owner/repo/issues/7/labels/3'); assert.equal(calls[1].opts.method, 'DELETE'); });