Files
TREK/server/tests/unit/nest/system-notices.branch-coverage.test.ts
T

170 lines
10 KiB
TypeScript

/**
* SystemNoticesService.getActiveFor — M3 branch ratchet (task-7-review.md).
*
* A separate, isolated file from `system-notices.service.test.ts` so these
* fixtures (deliberately shaped to hit specific fallback/tie-break branches)
* never leak into that file's shared `SYSTEM_NOTICES` mock and its exact
* `.toEqual([...])` assertions. Covers:
* - `addonEnabled`/`settingFlag`'s `?? false` fallback for a key that
* never appears in ANY notice's own conditions (so the up-front flag
* map, built by scanning every notice's conditions, never populates it)
* — only reachable via a registered `case 'custom'` predicate that asks
* about a DIFFERENT key than any `addonEnabled`/`settingFlag` condition
* names.
* - a per-version-recurring notice's dismissal row with a NULL
* `dismissed_app_version` (an old dismissal from before the per-version
* feature existed).
* - the sort comparator's three tie-break levels (priority, severity,
* publishedAt), each of which needs at least two notices active in the
* SAME call for `.sort()` to ever invoke the comparator at all.
*/
import { describe, it, expect, vi } from 'vitest';
import type { SystemNotice } from '../../../src/systemNotices/types';
const { PRIORITY_HI, PRIORITY_LO, SEVERITY_HI, SEVERITY_LO, PUBLISHED_NEW, PUBLISHED_OLD, ADDON_FLAG_MISS, SETTING_FLAG_MISS, RELEASE } =
vi.hoisted(() => {
const base = { display: 'toast' as const, dismissible: true, conditions: [{ kind: 'always' as const }], publishedAt: '2020-01-01T00:00:00Z' };
const priorityHi: SystemNotice = { ...base, id: 'sn-priority-hi', severity: 'info', titleKey: 't', bodyKey: 'b', priority: 5 };
// No `priority` field at all (not just 0) — exercises the `b.priority ??
// 0` / `a.priority ?? 0` fallback itself, not just a priority of 0.
const priorityLo: SystemNotice = { ...base, id: 'sn-priority-lo', severity: 'info', titleKey: 't', bodyKey: 'b' };
const severityHi: SystemNotice = { ...base, id: 'sn-severity-hi', severity: 'critical', titleKey: 't', bodyKey: 'b', priority: 0 };
const severityLo: SystemNotice = { ...base, id: 'sn-severity-lo', severity: 'info', titleKey: 't', bodyKey: 'b', priority: 0 };
const publishedNew: SystemNotice = { ...base, id: 'sn-published-new', severity: 'info', titleKey: 't', bodyKey: 'b', priority: 0, publishedAt: '2024-06-01T00:00:00Z' };
const publishedOld: SystemNotice = { ...base, id: 'sn-published-old', severity: 'info', titleKey: 't', bodyKey: 'b', priority: 0, publishedAt: '2020-01-01T00:00:00Z' };
const addonFlagMiss: SystemNotice = {
id: 'sn-addon-flag-miss', display: 'toast', severity: 'info', titleKey: 't', bodyKey: 'b', dismissible: true,
conditions: [{ kind: 'custom', id: 'branch-cov-addon-probe' }], publishedAt: '2020-01-01T00:00:00Z', priority: 0,
};
const settingFlagMiss: SystemNotice = {
id: 'sn-setting-flag-miss', display: 'toast', severity: 'info', titleKey: 't', bodyKey: 'b', dismissible: true,
conditions: [{ kind: 'custom', id: 'branch-cov-setting-probe' }], publishedAt: '2020-01-01T00:00:00Z', priority: 0,
};
const release: SystemNotice = {
id: 'sn-release-nullver', display: 'modal', severity: 'info', titleKey: 'system_notice.release_notes.headline', bodyKey: 'system_notice.release_notes.intro',
dismissible: true, conditions: [], recurring: 'per-version', publishedAt: '2020-01-01T00:00:00Z', priority: 0,
release: { version: '4.3.0', headlineKey: 'system_notice.release_notes.headline' } as SystemNotice['release'],
};
return {
PRIORITY_HI: priorityHi, PRIORITY_LO: priorityLo, SEVERITY_HI: severityHi, SEVERITY_LO: severityLo,
PUBLISHED_NEW: publishedNew, PUBLISHED_OLD: publishedOld, ADDON_FLAG_MISS: addonFlagMiss, SETTING_FLAG_MISS: settingFlagMiss, RELEASE: release,
};
});
vi.mock('../../../src/systemNotices/registry', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../../src/systemNotices/registry')>();
return {
...actual,
SYSTEM_NOTICES: [PRIORITY_HI, PRIORITY_LO, SEVERITY_HI, SEVERITY_LO, PUBLISHED_NEW, PUBLISHED_OLD, ADDON_FLAG_MISS, SETTING_FLAG_MISS, RELEASE],
};
});
import { SystemNoticesService } from '../../../src/nest/system-notices/system-notices.service';
import { getCurrentAppVersion } from '../../../src/systemNotices/service';
import { registerPredicate } from '../../../src/systemNotices/conditions';
import type { UserRow } from '../../../src/db/repositories/Users.repository';
import type { UserNoticeDismissalRow } from '../../../src/db/repositories/UserNoticeDismissals.repository';
// Neither key is asked about by any `addonEnabled`/`settingFlag` condition
// anywhere in the fixtures above, so the up-front flag map never populates
// them — the only way to reach them is a `case 'custom'` predicate calling
// `ctx.addonEnabled`/`ctx.settingFlag` with a key of its own choosing.
const addonProbe = vi.fn((ctx: { addonEnabled: (id: string) => boolean }) => ctx.addonEnabled('addon-nobody-asks-about') === false);
const settingProbe = vi.fn((ctx: { settingFlag: (key: string) => boolean }) => ctx.settingFlag('setting-nobody-asks-about') === false);
registerPredicate('branch-cov-addon-probe', addonProbe);
registerPredicate('branch-cov-setting-probe', settingProbe);
const BASE_USER = { id: 7, login_count: 5, first_seen_version: '3.0.0', role: 'user' } as UserRow;
function makeService(overrides: { listForUser?: UserNoticeDismissalRow[] } = {}) {
const findById = vi.fn(async () => BASE_USER);
const countForUser = vi.fn(async () => 0);
const listForUser = vi.fn(async () => overrides.listForUser ?? []);
const upsertDismissal = vi.fn(async () => undefined);
const isAddonEnabled = vi.fn(async () => false);
const getValue = vi.fn(async () => null);
const isManaged = vi.fn(() => false);
const svc = new SystemNoticesService(
{ isAddonEnabled } as never,
{ isManaged } as never,
{ findById } as never,
{ countForUser } as never,
{ listForUser, upsertDismissal } as never,
{ getValue } as never,
);
return { svc, isAddonEnabled };
}
describe('SystemNoticesService.getActiveFor — M3 branch coverage', () => {
it('addonEnabled resolves false (not undefined/throw) for a key no condition ever names, never calling the injected AddonsService for it', async () => {
const { svc, isAddonEnabled } = makeService();
const ids = (await svc.getActiveFor(BASE_USER.id)).map((n) => n.id);
expect(ids).toContain('sn-addon-flag-miss');
expect(addonProbe).toHaveReturnedWith(true);
expect(isAddonEnabled).not.toHaveBeenCalledWith('addon-nobody-asks-about');
});
it('settingFlag resolves false (not undefined/throw) for a key no condition ever names', async () => {
const { svc } = makeService();
const ids = (await svc.getActiveFor(BASE_USER.id)).map((n) => n.id);
expect(ids).toContain('sn-setting-flag-miss');
expect(settingProbe).toHaveReturnedWith(true);
});
it('a per-version notice dismissed by a row with a NULL dismissed_app_version (a pre-per-version dismissal) re-shows, same as an empty-string version', async () => {
const { svc } = makeService({ listForUser: [{ notice_id: 'sn-release-nullver', dismissed_app_version: null }] });
const ids = (await svc.getActiveFor(BASE_USER.id, new Set(['release']), getCurrentAppVersion())).map((n) => n.id);
expect(ids).toContain('sn-release-nullver');
});
it('a per-version notice dismissed by a row whose dismissed_app_version semver cannot coerce falls back to 0.0.0, same as re-showing', async () => {
const { svc } = makeService({ listForUser: [{ notice_id: 'sn-release-nullver', dismissed_app_version: 'not-a-semver-string' }] });
const ids = (await svc.getActiveFor(BASE_USER.id, new Set(['release']), getCurrentAppVersion())).map((n) => n.id);
expect(ids).toContain('sn-release-nullver');
});
it('sorts by priority first: a higher-priority notice comes before a same-severity, same-date, lower-priority one', async () => {
const { svc } = makeService();
const order = (await svc.getActiveFor(BASE_USER.id)).map((n) => n.id).filter((id) => id === 'sn-priority-hi' || id === 'sn-priority-lo');
expect(order).toEqual(['sn-priority-hi', 'sn-priority-lo']);
});
it('falls through to severity when priority ties: a critical notice comes before a same-priority, same-date info one', async () => {
const { svc } = makeService();
const order = (await svc.getActiveFor(BASE_USER.id)).map((n) => n.id).filter((id) => id === 'sn-severity-hi' || id === 'sn-severity-lo');
expect(order).toEqual(['sn-severity-hi', 'sn-severity-lo']);
});
it('falls through to publishedAt when priority AND severity tie: the newer notice comes first', async () => {
const { svc } = makeService();
const order = (await svc.getActiveFor(BASE_USER.id)).map((n) => n.id).filter((id) => id === 'sn-published-new' || id === 'sn-published-old');
expect(order).toEqual(['sn-published-new', 'sn-published-old']);
});
// The running app version is only ever the real getCurrentAppVersion() in
// every other test in this file, which semver.coerce always parses — this
// is the one case that forces the '0.0.0' fallback used for the
// dismissal-recency comparison.
it('a running app version semver cannot coerce falls back to 0.0.0 instead of throwing', async () => {
vi.resetModules();
vi.doMock('../../../src/systemNotices/service', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../../src/systemNotices/service')>();
return { ...actual, getCurrentAppVersion: () => 'not-a-semver-string' };
});
try {
const { SystemNoticesService: Reloaded } = await import('../../../src/nest/system-notices/system-notices.service');
const inst = new Reloaded(
{ isAddonEnabled: async () => false } as never,
{ isManaged: () => false } as never,
{ findById: async () => BASE_USER } as never,
{ countForUser: async () => 0 } as never,
{ listForUser: async () => [{ notice_id: 'sn-release-nullver', dismissed_app_version: '1.0.0' }] } as never,
{ getValue: async () => null } as never,
);
await expect(inst.getActiveFor(BASE_USER.id)).resolves.toEqual(expect.any(Array));
} finally {
vi.doUnmock('../../../src/systemNotices/service');
}
});
});