832f7bad45
Closes #567. The sidebar already had a "vX.Y.Z available" indicator (#566 made it a link to that release's page) but there was no way to read the actual changelog inline or to grab a copy-paste upgrade command. This adds the modal the issue spec'd, layered on top of the existing updateCheckService / environmentService backend infrastructure that already shipped. ## Backend - `updateCheckService.fetchAvailableVersions` now returns full release objects (tag, name, body, publishedAt, htmlUrl) instead of just version strings — body data is what the changelog modal renders. `checkForUpdates` extracts the version strings for its existing consumers; no API change visible to callers. - New `getReleasesSince(currentVersion, channel)` returns the list of releases strictly newer than current, filtered to the user's channel. Reuses the same 1-hour cache as `checkForUpdates` so the modal opening doesn't trigger an extra GitHub round-trip. - New `GET /admin/system/updates/changelog` route in `adminSystem.js`, same auth + UPDATE_CHECK_ENABLED gating as the existing /updates and /updates/instructions endpoints. - 4 unit tests (axios mocked) pin: strictly-newer filtering, channel-scoped, empty array on GitHub fetch failure, empty array when already on latest. ## Frontend - New `UpdateAvailableModal.tsx` — opens from the sidebar chip. Two sections: 1. **How to upgrade** — fetches /updates/instructions for the environment-detected copy-paste command (Docker compose / git / standalone). Copy-to-clipboard button per step. 2. **Release notes** — fetches /updates/changelog for every version between current and latest in the user's channel. Latest is auto-expanded; older releases are collapsed by default (click to expand). Each release also has a "View on GitHub" link to the canonical release page. - Renders release body markdown through the existing safe MarkdownContent component (marked + DOMPurify allowlist). - New `updateDismissal.ts` helper — single localStorage key holds the last-dismissed version. Chip stays hidden until a STRICTLY newer version appears, using the same compare semantics as the backend (stable > beta, higher beta > lower beta, semantic numeric on major.minor.patch). 9 unit tests pin the rules. - `VersionInfo.tsx` — chip is now a button that opens the modal instead of an external link (the #566 link-to-release behaviour is preserved on the modal's per-release "View on GitHub" affordance). Dismissal triggers an immediate re-render so the chip disappears without waiting for the next route change. No new dependencies — uses `marked` + `DOMPurify` that were already present in the bundle for the contract block renderer.
102 lines
3.4 KiB
JavaScript
102 lines
3.4 KiB
JavaScript
/**
|
|
* Coverage for the changelog aggregation introduced for #567.
|
|
*
|
|
* `getReleasesSince` is what feeds the update-available modal so the
|
|
* admin can see release notes for every version between their current
|
|
* version and latest. The cases below pin:
|
|
*
|
|
* - Strictly-newer filtering (the running version itself never
|
|
* appears in the list).
|
|
* - Channel filtering (a stable user does not see beta releases,
|
|
* and vice versa).
|
|
* - Empty-result fallback when the GitHub fetch returns nothing
|
|
* (cached null from `fetchAvailableVersions`).
|
|
*
|
|
* We mock axios's GitHub response — these tests are pure logic, no
|
|
* network. Cache is cleared between cases via the service's exported
|
|
* `clearCache()` to avoid bleed.
|
|
*/
|
|
|
|
jest.mock('axios');
|
|
const axios = require('axios');
|
|
const { getReleasesSince, clearCache } = require('../../src/services/updateCheckService');
|
|
|
|
function release(tag, body = '', publishedAt = '2026-01-01T00:00:00Z') {
|
|
return {
|
|
tag_name: tag,
|
|
name: tag,
|
|
body,
|
|
published_at: publishedAt,
|
|
html_url: `https://github.com/the-luap/picpeak/releases/tag/${tag}`,
|
|
};
|
|
}
|
|
|
|
describe('updateCheckService.getReleasesSince', () => {
|
|
beforeEach(() => {
|
|
clearCache();
|
|
axios.get.mockReset();
|
|
});
|
|
|
|
it('returns only releases strictly newer than current, for the requested channel', async () => {
|
|
axios.get.mockResolvedValue({
|
|
data: [
|
|
release('v3.55.0', 'stable notes 3.55.0'),
|
|
release('v3.54.0', 'stable notes 3.54.0'),
|
|
release('v3.43.1', 'stable notes 3.43.1 — the running version, should be excluded'),
|
|
release('v3.43.0'),
|
|
release('v3.55.0-beta.0', 'beta notes — wrong channel, excluded'),
|
|
release('v3.54.0-beta.5'),
|
|
],
|
|
});
|
|
|
|
const result = await getReleasesSince('3.43.1', 'stable');
|
|
|
|
expect(result.map((r) => r.version)).toEqual(['3.55.0', '3.54.0']);
|
|
// Body + html_url + publishedAt are preserved so the modal can render them
|
|
expect(result[0]).toMatchObject({
|
|
version: '3.55.0',
|
|
tag: 'v3.55.0',
|
|
name: 'v3.55.0',
|
|
body: 'stable notes 3.55.0',
|
|
htmlUrl: 'https://github.com/the-luap/picpeak/releases/tag/v3.55.0',
|
|
});
|
|
});
|
|
|
|
it('returns only beta releases for a beta user', async () => {
|
|
axios.get.mockResolvedValue({
|
|
data: [
|
|
release('v3.55.0-beta.0'),
|
|
release('v3.54.7-beta.0'),
|
|
release('v3.55.0'), // stable — wrong channel for a beta user
|
|
release('v3.54.6-beta.0'),
|
|
],
|
|
});
|
|
|
|
const result = await getReleasesSince('3.54.6-beta.0', 'beta');
|
|
|
|
// Strictly newer beta-channel only — does not include 3.55.0 stable
|
|
// even though it's a newer release, because the beta channel user
|
|
// wants to see beta releases (which can include releases that
|
|
// landed on the beta line after the stable cut).
|
|
expect(result.map((r) => r.version)).toEqual(['3.55.0-beta.0', '3.54.7-beta.0']);
|
|
});
|
|
|
|
it('returns empty array when GitHub fetch fails (e.g. rate-limited)', async () => {
|
|
axios.get.mockRejectedValue(new Error('API rate limit exceeded'));
|
|
|
|
const result = await getReleasesSince('3.43.1', 'stable');
|
|
|
|
expect(result).toEqual([]);
|
|
});
|
|
|
|
it('returns empty array when the user is already on the latest version', async () => {
|
|
axios.get.mockResolvedValue({
|
|
data: [release('v3.55.0'), release('v3.54.0')],
|
|
});
|
|
|
|
const result = await getReleasesSince('3.55.0', 'stable');
|
|
|
|
expect(result).toEqual([]);
|
|
});
|
|
});
|