- 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>
211 lines
11 KiB
JavaScript
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' });
|
|
});
|
|
});
|