Files
events-hub/backend/tests/releaseSources.test.js
joshuaandClaude Opus 5.5 816452e1c2 Hub 2.0 step 3: releases per product, with requirements
- Release.product and requires; unique (product, version); latest version
  per product (latest_version stays Cross Events' latest)
- Release sources per product (Releases page): repository, tag prefix,
  notes file. Defaults: every product from crosscode-platform plus Cross
  Events' v1.2.3 tags from hope-events. A product's releases are only
  removed when all its sources were read and found tags
- "— requires accounts 1.2.0" in a notes heading: Request update answers
  409 REQUIREMENT_NOT_MET until the church's install of that product is new
  enough; install page and Releases page show it; v2 replies carry requires
- @crosscode/contracts 1.1.0 vendored (optional requires on releases)
- Alerts, dashboard and install lists compare against each product's latest

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 15:57:46 +02:00

211 lines
11 KiB
JavaScript

// Hub 2.0 step 3: releases per product from several git sources, requirements in the notes, and
// the rules that keep an unreachable source from wiping releases. Gitea is faked with a fetch
// stub; the database is in memory.
const mockReleases = [];
const mockSettings = { values: {} };
const mockDb = {
release: {
findMany: jest.fn(async ({ where, select } = {}) => {
const rows = mockReleases.filter(r => !where?.product || r.product === where.product);
return select ? rows.map(r => Object.fromEntries(Object.keys(select).map(k => [k, r[k]]))) : rows;
}),
create: jest.fn(async ({ data }) => { const row = { id: `r${mockReleases.length + 1}_${data.product}_${data.version}`, requires: [], ...data }; mockReleases.push(row); return row; }),
update: jest.fn(async ({ where, data }) => Object.assign(mockReleases.find(r => r.id === where.id), data)),
deleteMany: jest.fn(async ({ where }) => {
const ids = new Set(where.id.in);
for (let i = mockReleases.length - 1; i >= 0; i--) if (ids.has(mockReleases[i].id)) mockReleases.splice(i, 1);
return { count: ids.size };
}),
},
};
jest.mock('../src/config/db', () => mockDb);
jest.mock('../src/utils/settings', () => ({
getSettings: jest.fn(async () => ({ ...mockSettings.values })),
setSettings: jest.fn(async v => { Object.assign(mockSettings.values, v); return mockSettings.values; }),
releaseRepoToken: jest.fn(async () => ''),
}));
const { parseNotes, parseRequirements, tagPattern, defaultSources, cleanSources, releaseSources, syncReleases, PLATFORM_REPO } = require('../src/utils/releaseSync');
const { unmetRequirements, latestByProduct } = require('../src/utils/releases');
const LEGACY = 'https://c-git.crosscode.co.za/joshua/hope-events';
const api = repo => `https://c-git.crosscode.co.za/api/v1/repos/joshua/${repo}`;
/** A fake Gitea: { 'hope-events': { tags: [...], files: { 'WHATS-NEW.md': '...' } } }; a repo set to 'down' fails. */
let gitea;
global.fetch = jest.fn(async url => {
const m = /\/repos\/joshua\/([^/]+)\/(tags|raw\/(.+))/.exec(url);
const repo = m && gitea[m[1]];
if (repo === 'down') throw new Error('connect ECONNREFUSED');
if (!repo) return { ok: false, status: 404 };
if (m[2].startsWith('tags')) {
const page = Number(new URL(url).searchParams.get('page'));
return { ok: true, status: 200, json: async () => (page === 1 ? repo.tags.map(name => ({ name, commit: { created: '2026-11-01T10:00:00Z' } })) : []) };
}
const file = repo.files?.[decodeURIComponent(m[3])];
return file == null ? { ok: false, status: 404 } : { ok: true, status: 200, text: async () => file };
});
beforeEach(() => {
mockReleases.length = 0;
mockSettings.values = { release_repo_url: LEGACY, release_notes_path: 'WHATS-NEW.md' };
gitea = {
'hope-events': { tags: ['v2.0.0', 'v2.1.0', 'v2.1.1', 'not-a-version'], files: { 'WHATS-NEW.md': '## 2.1.1\n- Feedback in the background\n## 2.1.0\n- Feedback page' } },
'crosscode-platform': {
tags: ['contracts@1.0.0', 'contracts@1.1.0', 'events@2.2.0', 'events@3.0.0', 'accounts@1.2.0', 'website@1.0.0'],
files: {
'apps/events/WHATS-NEW.md': '## 3.0.0 — requires accounts 1.2.0\n- Sign in with your church account\n## 2.2.0\n- Moved into the monorepo',
'apps/accounts/WHATS-NEW.md': '## 1.2.0\n- Authenticator app',
'apps/website/WHATS-NEW.md': '## 1.0.0 — requires events 2.4.0\n- First release',
},
},
};
});
const versions = product => mockReleases.filter(r => r.product === product).map(r => r.version).sort();
describe('sources', () => {
test('the defaults: every product from the monorepo, plus Cross Events from the repo set before 2.0', () => {
expect(defaultSources(mockSettings.values)).toEqual([
{ product: 'events', repo: PLATFORM_REPO, tagPrefix: 'events@', notesPath: 'apps/events/WHATS-NEW.md' },
{ product: 'events', repo: LEGACY, tagPrefix: 'v', notesPath: 'WHATS-NEW.md' },
{ product: 'accounts', repo: PLATFORM_REPO, tagPrefix: 'accounts@', notesPath: 'apps/accounts/WHATS-NEW.md' },
{ product: 'website', repo: PLATFORM_REPO, tagPrefix: 'website@', notesPath: 'apps/website/WHATS-NEW.md' },
{ product: 'app', repo: PLATFORM_REPO, tagPrefix: 'app@', notesPath: 'apps/app/WHATS-NEW.md' },
]);
});
test('a saved list replaces the defaults', () => {
const saved = [{ product: 'events', repo: LEGACY, tagPrefix: 'v', notesPath: 'WHATS-NEW.md' }];
expect(releaseSources({ release_sources: JSON.stringify(saved) })).toEqual(saved);
});
test('bad sources are refused with what is wrong', () => {
expect(() => cleanSources([{ product: 'shop', repo: LEGACY }])).toThrow(/product/);
expect(() => cleanSources([{ product: 'events', repo: 'not a url' }])).toThrow(/repository/);
expect(() => cleanSources([{ product: 'events', repo: LEGACY, tagPrefix: 'a b' }])).toThrow(/prefix/);
expect(() => cleanSources([{ product: 'events', repo: LEGACY, notesPath: '../x' }])).toThrow(/notes/);
expect(() => cleanSources([{ product: 'events', repo: LEGACY, tagPrefix: 'v' }, { product: 'events', repo: `${LEGACY}.git`, tagPrefix: 'v' }])).toThrow(/twice/);
});
test('tag patterns: a prefix, and v (also bare versions, as before 2.0)', () => {
expect(tagPattern('events@').exec('events@2.2.0')[1]).toBe('2.2.0');
expect(tagPattern('events@').exec('website@2.2.0')).toBeNull();
expect(tagPattern('events@').exec('events@2.2.0-rc1')).toBeNull();
expect(tagPattern('v').exec('v2.1.1')[1]).toBe('2.1.1');
expect(tagPattern('v').exec('2.1.1')[1]).toBe('2.1.1');
});
});
describe('requirements in the notes', () => {
test('"— requires accounts 1.2.0, website v1.1.0 — urgent"', () => {
expect(parseNotes('## 3.0.0 — requires accounts 1.2.0, website v1.1.0 — urgent\n- x')['3.0.0']).toEqual({
notes: '- x', urgent: true, requires: [{ product: 'accounts', version: '1.2.0' }, { product: 'website', version: '1.1.0' }],
});
});
test('no "requires" means no requirements, even if a product is named', () => {
expect(parseRequirements(' — works with the website 1.0.0')).toEqual([]);
expect(parseNotes('## 2.4.0\n- x')['2.4.0'].requires).toEqual([]);
});
});
describe('sync', () => {
test('every product gets the tags of its sources, with notes and requirements', async () => {
const result = await syncReleases();
expect(versions('events')).toEqual(['2.0.0', '2.1.0', '2.1.1', '2.2.0', '3.0.0']);
expect(versions('accounts')).toEqual(['1.2.0']);
expect(versions('website')).toEqual(['1.0.0']);
expect(versions('app')).toEqual([]);
expect(mockReleases.find(r => r.product === 'events' && r.version === '3.0.0').requires).toEqual([{ product: 'accounts', version: '1.2.0' }]);
expect(mockReleases.find(r => r.product === 'events' && r.version === '2.1.1').notes).toBe('- Feedback in the background');
expect(result.latest).toEqual({ events: '3.0.0', accounts: '1.2.0', website: '1.0.0' });
expect(mockSettings.values.latest_version).toBe('3.0.0');
});
test('contracts@ tags are nobody\'s release', async () => {
await syncReleases();
expect(mockReleases.some(r => r.version === '1.1.0')).toBe(false);
});
test('a removed tag removes its release', async () => {
await syncReleases();
gitea['hope-events'].tags = ['v2.1.0', 'v2.1.1'];
const result = await syncReleases();
expect(versions('events')).toEqual(['2.1.0', '2.1.1', '2.2.0', '3.0.0']);
expect(result.removed).toBe(1);
});
test('a source that can\'t be reached keeps its product\'s releases', async () => {
await syncReleases();
gitea['hope-events'] = 'down';
const result = await syncReleases();
expect(versions('events')).toEqual(['2.0.0', '2.1.0', '2.1.1', '2.2.0', '3.0.0']);
expect(result.problems.join(' ')).toMatch(/hope-events.*ECONNREFUSED/);
// The other products still sync.
gitea['crosscode-platform'].tags.push('accounts@1.3.0');
await syncReleases();
expect(versions('accounts')).toEqual(['1.2.0', '1.3.0']);
});
test('a product whose sources have no tags any more keeps its releases (and says so)', async () => {
mockSettings.values.release_sources = JSON.stringify([{ product: 'events', repo: LEGACY, tagPrefix: 'v', notesPath: 'WHATS-NEW.md' }]);
await syncReleases();
gitea['hope-events'].tags = [];
const result = await syncReleases();
expect(versions('events')).toEqual(['2.0.0', '2.1.0', '2.1.1']);
expect(result.problems.join(' ')).toMatch(/kept/);
});
test('when nothing can be read, the sync fails and changes nothing', async () => {
await syncReleases();
gitea['hope-events'] = 'down';
gitea['crosscode-platform'] = 'down';
await expect(syncReleases()).rejects.toThrow(/ECONNREFUSED/);
expect(versions('events')).toHaveLength(5);
});
test('a product with no source is left alone (managed by hand)', async () => {
mockReleases.push({ id: 'manual', product: 'app', version: '0.9.0', notes: '', urgent: false, requires: [], releasedAt: new Date() });
mockSettings.values.release_sources = JSON.stringify([{ product: 'events', repo: LEGACY, tagPrefix: 'v', notesPath: 'WHATS-NEW.md' }]);
await syncReleases();
expect(versions('app')).toEqual(['0.9.0']);
});
test('a requirement can\'t be on the product itself', async () => {
gitea['crosscode-platform'].files['apps/events/WHATS-NEW.md'] = '## 3.0.0 — requires events 2.9.0, accounts 1.2.0\n- x';
await syncReleases();
expect(mockReleases.find(r => r.product === 'events' && r.version === '3.0.0').requires).toEqual([{ product: 'accounts', version: '1.2.0' }]);
});
});
describe('unmet requirements', () => {
const releases = [
{ version: '2.4.0', requires: [] },
{ version: '2.5.0', requires: [{ product: 'accounts', version: '1.1.0' }] },
{ version: '3.0.0', requires: [{ product: 'accounts', version: '1.2.0' }, { product: 'website', version: '1.0.0' }] },
{ version: '3.1.0', requires: [{ product: 'accounts', version: '1.5.0' }] },
];
test('the highest requirement over every release up to the target counts', () => {
expect(unmetRequirements({ releases, from: '2.4.0', target: '3.0.0', siblings: [{ product: 'accounts', version: '1.1.0' }] })).toEqual([
{ product: 'accounts', version: '1.2.0', current: '1.1.0' },
{ product: 'website', version: '1.0.0', current: null },
]);
});
test('releases already installed, or beyond the target, don\'t count', () => {
expect(unmetRequirements({ releases, from: '2.5.0', target: '2.5.0', siblings: [] })).toEqual([]);
expect(unmetRequirements({ releases, from: '3.0.0', target: '3.1.0', siblings: [{ product: 'accounts', version: '1.5.0' }] })).toEqual([]);
});
test('met when the sibling runs that version or later', () => {
expect(unmetRequirements({ releases, from: '2.4.0', target: '3.0.0', siblings: [{ product: 'accounts', version: '1.2.1' }, { product: 'website', version: '1.0.0' }] })).toEqual([]);
});
test('latest per product', () => {
expect(latestByProduct([{ product: 'events', version: '2.10.0' }, { product: 'events', version: '2.9.0' }, { product: 'app', version: '1.0.0' }])).toEqual({ events: '2.10.0', app: '1.0.0' });
});
});