feat(system-notices): replace expiresAt with [minVersion, maxVersion) version gate

Prevents users who upgrade across multiple versions from seeing all
interim notices at once. Version bounds are evaluated server-side using
semver.coerce so prerelease builds compare as their base release.
Range is lower-inclusive, upper-exclusive: maxVersion: '4.0.0' hides
the notice once 4.0.0 ships.
This commit is contained in:
jubnl
2026-04-17 20:03:23 +02:00
parent 8cd5aa0d23
commit 5952e02971
6 changed files with 117 additions and 14 deletions
@@ -1,6 +1,7 @@
import { describe, it, expect } from 'vitest';
import fs from 'node:fs';
import path from 'node:path';
import semver from 'semver';
import { SYSTEM_NOTICES } from '../../../src/systemNotices/registry.js';
/** Collect all actionIds registered via registerNoticeAction() in client source files. */
@@ -45,4 +46,21 @@ describe('registry integrity', () => {
expect(() => new Date(n.publishedAt).toISOString()).not.toThrow();
}
});
it('minVersion and maxVersion are valid semver when set, and minVersion <= maxVersion when both set', () => {
for (const n of SYSTEM_NOTICES) {
if (n.minVersion !== undefined) {
expect(semver.valid(n.minVersion), `notice "${n.id}" has invalid minVersion "${n.minVersion}"`).not.toBeNull();
}
if (n.maxVersion !== undefined) {
expect(semver.valid(n.maxVersion), `notice "${n.id}" has invalid maxVersion "${n.maxVersion}"`).not.toBeNull();
}
if (n.minVersion && n.maxVersion) {
expect(
semver.lte(n.minVersion, n.maxVersion),
`notice "${n.id}": minVersion ${n.minVersion} > maxVersion ${n.maxVersion}`
).toBe(true);
}
}
});
});
@@ -0,0 +1,56 @@
import { describe, it, expect } from 'vitest';
import { isNoticeVersionActive } from '../../../src/systemNotices/service.js';
import type { SystemNotice } from '../../../src/systemNotices/types.js';
const base: SystemNotice = {
id: 'test-notice',
display: 'modal',
severity: 'info',
titleKey: 'k',
bodyKey: 'k',
dismissible: true,
conditions: [],
publishedAt: '2026-01-01T00:00:00Z',
};
describe('isNoticeVersionActive', () => {
it('passes when no bounds are set', () => {
expect(isNoticeVersionActive(base, '3.5.0')).toBe(true);
});
it('passes when app version equals minVersion (inclusive)', () => {
expect(isNoticeVersionActive({ ...base, minVersion: '3.0.0' }, '3.0.0')).toBe(true);
});
it('fails when app version is below minVersion', () => {
expect(isNoticeVersionActive({ ...base, minVersion: '3.0.0' }, '2.9.9')).toBe(false);
});
it('fails when app version equals maxVersion (exclusive upper bound)', () => {
expect(isNoticeVersionActive({ ...base, maxVersion: '3.0.0' }, '3.0.0')).toBe(false);
});
it('fails when app version exceeds maxVersion', () => {
expect(isNoticeVersionActive({ ...base, maxVersion: '3.0.0' }, '3.0.1')).toBe(false);
});
it('passes when app version is just below maxVersion', () => {
expect(isNoticeVersionActive({ ...base, maxVersion: '3.0.0' }, '2.9.9')).toBe(true);
});
it('passes when app version is inside [minVersion, maxVersion)', () => {
expect(isNoticeVersionActive({ ...base, minVersion: '3.0.0', maxVersion: '3.9.9' }, '3.5.2')).toBe(true);
});
it('treats prerelease app version as base release (semver.coerce)', () => {
expect(isNoticeVersionActive({ ...base, minVersion: '3.0.0' }, '3.0.0-pre.42')).toBe(true);
});
it('skips notice and returns false when minVersion is invalid semver', () => {
expect(isNoticeVersionActive({ ...base, minVersion: 'not-semver' }, '3.0.0')).toBe(false);
});
it('skips notice and returns false when maxVersion is invalid semver', () => {
expect(isNoticeVersionActive({ ...base, maxVersion: 'garbage' }, '3.0.0')).toBe(false);
});
});