Merge pull request #575 from the-luap/feat/clickable-version-links-566
feat(admin): clickable version links + update-available modal with changelog & upgrade command
This commit is contained in:
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* 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([]);
|
||||
});
|
||||
});
|
||||
@@ -7,7 +7,7 @@ const path = require('path');
|
||||
const os = require('os');
|
||||
const { formatBoolean } = require('../utils/dbCompat');
|
||||
const logger = require('../utils/logger');
|
||||
const { checkForUpdates, getCurrentChannel } = require('../services/updateCheckService');
|
||||
const { checkForUpdates, getCurrentChannel, getReleasesSince } = require('../services/updateCheckService');
|
||||
const { detectEnvironment, generateUpdateInstructions } = require('../services/environmentService');
|
||||
const {
|
||||
checkAndNotifyUpdates,
|
||||
@@ -71,6 +71,33 @@ router.get('/updates', adminAuth, requirePermission('settings.view'), async (req
|
||||
}
|
||||
});
|
||||
|
||||
// Aggregated changelog — every release between current and latest in
|
||||
// the user's channel. Powers the update-available modal (#567) so the
|
||||
// admin can read release notes for ALL versions they're behind on, not
|
||||
// just the latest. Body is raw GitHub-flavoured markdown; rendering is
|
||||
// the client's job (frontend uses `marked`).
|
||||
router.get('/updates/changelog', adminAuth, requirePermission('settings.view'), async (req, res) => {
|
||||
try {
|
||||
const updateCheckEnabled = process.env.UPDATE_CHECK_ENABLED !== 'false';
|
||||
if (!updateCheckEnabled) {
|
||||
return res.json({ enabled: false, releases: [] });
|
||||
}
|
||||
|
||||
const updateInfo = await checkForUpdates();
|
||||
const releases = await getReleasesSince(updateInfo.current, updateInfo.channel);
|
||||
|
||||
res.json({
|
||||
enabled: true,
|
||||
current: updateInfo.current,
|
||||
channel: updateInfo.channel,
|
||||
releases,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Error fetching update changelog:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch changelog' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get update instructions for current environment
|
||||
router.get('/updates/instructions', adminAuth, requirePermission('settings.view'), async (req, res) => {
|
||||
try {
|
||||
|
||||
@@ -105,7 +105,10 @@ async function fetchAvailableVersions() {
|
||||
}
|
||||
);
|
||||
|
||||
// Extract version tags from releases
|
||||
// Extract version tags from releases. We keep the full release
|
||||
// object (name, body, publishedAt, htmlUrl) so the changelog modal
|
||||
// (#567) can render the release notes without a second GitHub API
|
||||
// round-trip per modal open.
|
||||
const versions = {
|
||||
stable: [],
|
||||
beta: []
|
||||
@@ -118,18 +121,25 @@ async function fetchAvailableVersions() {
|
||||
// Remove 'v' prefix if present
|
||||
const version = tag.startsWith('v') ? tag.substring(1) : tag;
|
||||
|
||||
const entry = {
|
||||
version,
|
||||
tag,
|
||||
name: release.name || tag,
|
||||
body: release.body || '',
|
||||
publishedAt: release.published_at || null,
|
||||
htmlUrl: release.html_url || null,
|
||||
};
|
||||
|
||||
if (version.match(/^\d+\.\d+\.\d+$/)) {
|
||||
// Stable version
|
||||
versions.stable.push(version);
|
||||
versions.stable.push(entry);
|
||||
} else if (version.match(/^\d+\.\d+\.\d+-beta\.\d+$/)) {
|
||||
// Beta version
|
||||
versions.beta.push(version);
|
||||
versions.beta.push(entry);
|
||||
}
|
||||
}
|
||||
|
||||
// Sort versions descending (newest first)
|
||||
versions.stable.sort((a, b) => compareVersions(b, a));
|
||||
versions.beta.sort((a, b) => compareVersions(b, a));
|
||||
versions.stable.sort((a, b) => compareVersions(b.version, a.version));
|
||||
versions.beta.sort((a, b) => compareVersions(b.version, a.version));
|
||||
|
||||
return versions;
|
||||
} catch (error) {
|
||||
@@ -162,9 +172,12 @@ async function checkForUpdates(forceRefresh = false) {
|
||||
};
|
||||
}
|
||||
|
||||
// Determine latest version for current channel
|
||||
const latestStable = availableVersions.stable[0] || currentVersion;
|
||||
const latestBeta = availableVersions.beta[0] || currentVersion;
|
||||
// Determine latest version for current channel. Entry shape is
|
||||
// `{version, tag, name, body, publishedAt, htmlUrl}` since the rich
|
||||
// changelog work (#567); fall back to current when GitHub returned
|
||||
// nothing (e.g. brand-new install before any releases exist).
|
||||
const latestStable = availableVersions.stable[0]?.version || currentVersion;
|
||||
const latestBeta = availableVersions.beta[0]?.version || currentVersion;
|
||||
const latestForChannel = currentChannel === 'beta' ? latestBeta : latestStable;
|
||||
|
||||
const updateAvailable = compareVersions(latestForChannel, currentVersion) > 0;
|
||||
@@ -194,6 +207,24 @@ async function checkForUpdates(forceRefresh = false) {
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the list of releases newer than the running version, filtered to
|
||||
* the user's channel. Powers the changelog modal (#567) so the admin
|
||||
* sees release notes for every version between current and latest,
|
||||
* not just the latest.
|
||||
*
|
||||
* @returns {Promise<Array<{version, tag, name, body, publishedAt, htmlUrl}>>}
|
||||
* Releases sorted newest-first. Empty array when no update available
|
||||
* or when the GitHub fetch failed.
|
||||
*/
|
||||
async function getReleasesSince(currentVersion, channel) {
|
||||
const availableVersions = await fetchAvailableVersions();
|
||||
if (!availableVersions) return [];
|
||||
|
||||
const list = channel === 'beta' ? availableVersions.beta : availableVersions.stable;
|
||||
return list.filter((entry) => compareVersions(entry.version, currentVersion) > 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the version cache (useful for testing)
|
||||
*/
|
||||
@@ -206,6 +237,7 @@ module.exports = {
|
||||
checkForUpdates,
|
||||
getCurrentVersion,
|
||||
getCurrentChannel,
|
||||
getReleasesSince,
|
||||
compareVersions,
|
||||
parseVersion,
|
||||
clearCache
|
||||
|
||||
Reference in New Issue
Block a user