mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 05:26:47 +00:00
724c88c201
A mechanical prettier --write over server/src, server/tests, server/scripts and shared/src with the workspaces' .prettierrc; nothing but layout and import order changes, and every file keeps the same set of imports. shared is formatted throughout and its format baseline is empty. The server baseline keeps the 35 files that Prettier would grow past their lint:size limit (they need a split first, which is not a formatting change), and the generated entity files stay as their generator writes them.
303 lines
15 KiB
TypeScript
303 lines
15 KiB
TypeScript
/**
|
|
* SystemNoticesService — Plan 3f Task 6 (systemNotices → Nest provider).
|
|
* Absorbed `getActiveNoticesFor`/`dismissNotice` from `../../../src/systemNotices/service.ts`;
|
|
* the pure evaluation logic they call through (`evaluate`/`isNoticeVersionActive`/
|
|
* `severityWeight`, `conditions.ts`) is unchanged and stays proven in
|
|
* `tests/unit/systemNotices/*.test.ts`. `tests/integration/systemNotices.test.ts`
|
|
* is the full-fidelity, unmocked proof (real HTTP → real DI service → real
|
|
* repositories → real seeded rows), unaffected by this task and left as-is.
|
|
*
|
|
* This file is a pure-DI unit suite: it proves the WIRING added/changed by
|
|
* this task — the four injected repositories feed `getActiveNoticesFor`'s SN2-4
|
|
* reads, `addonEnabled` and the new `settingFlag` map are each resolved once
|
|
* per call the same way, `getActiveFor`'s release-layout gating still holds,
|
|
* and `dismiss` still upserts through the composite-PK repository method with
|
|
* `Date.now()`/`getCurrentAppVersion()` — using a small, controlled registry
|
|
* (mocked at the `registry.ts` module boundary, real evaluation logic
|
|
* underneath) rather than a real SQLite DB, since none of what changed here
|
|
* is SQL shape (that is `UserNoticeDismissals.repository.test.ts`'s job).
|
|
*/
|
|
import type { AppSettingsRepository } from '../../../src/db/repositories/AppSettings.repository';
|
|
import type { TripsRepository } from '../../../src/db/repositories/Trips.repository';
|
|
import type {
|
|
UserNoticeDismissalsRepository,
|
|
UserNoticeDismissalRow,
|
|
} from '../../../src/db/repositories/UserNoticeDismissals.repository';
|
|
import type { UsersRepository, UserRow } from '../../../src/db/repositories/Users.repository';
|
|
import type { AddonsService } from '../../../src/nest/addons/addons.service';
|
|
import type { RuntimeEnvService } from '../../../src/nest/app-config/runtime-env.service';
|
|
import { SystemNoticesService } from '../../../src/nest/system-notices/system-notices.service';
|
|
import { getCurrentAppVersion } from '../../../src/systemNotices/service';
|
|
import type { SystemNotice } from '../../../src/systemNotices/types';
|
|
|
|
import { describe, it, expect, vi } from 'vitest';
|
|
|
|
// `vi.mock` factories are hoisted above every other statement in the file
|
|
// (including top-level `const`s declared earlier in source order), so the
|
|
// fixtures the factory below closes over have to go through `vi.hoisted`.
|
|
const { GENERIC_NOTICE, RELEASE_NOTICE, ADDON_NOTICE, CUSTOM_NOTICE } = vi.hoisted(() => {
|
|
const generic: SystemNotice = {
|
|
id: 'sn-test-generic',
|
|
display: 'banner',
|
|
severity: 'warn',
|
|
titleKey: 'system_notice.outage.title',
|
|
bodyKey: 'system_notice.outage.body',
|
|
dismissible: true,
|
|
conditions: [],
|
|
publishedAt: '2020-01-01T00:00:00Z',
|
|
priority: 0,
|
|
};
|
|
const release: SystemNotice = {
|
|
id: 'sn-test-release',
|
|
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'],
|
|
};
|
|
const addon: SystemNotice = {
|
|
id: 'sn-test-addon',
|
|
display: 'toast',
|
|
severity: 'info',
|
|
titleKey: 't',
|
|
bodyKey: 'b',
|
|
dismissible: true,
|
|
conditions: [{ kind: 'addonEnabled', addonId: 'journey' }],
|
|
publishedAt: '2020-01-01T00:00:00Z',
|
|
priority: 0,
|
|
};
|
|
const custom: SystemNotice = {
|
|
id: 'sn-test-custom',
|
|
display: 'toast',
|
|
severity: 'info',
|
|
titleKey: 't',
|
|
bodyKey: 'b',
|
|
dismissible: true,
|
|
conditions: [{ kind: 'custom', id: 'whitespace-collision-detected' }],
|
|
publishedAt: '2020-01-01T00:00:00Z',
|
|
priority: 0,
|
|
};
|
|
return { GENERIC_NOTICE: generic, RELEASE_NOTICE: release, ADDON_NOTICE: addon, CUSTOM_NOTICE: custom };
|
|
});
|
|
|
|
// A small, controlled registry — real evaluation logic (`evaluate`/`conditions.ts`,
|
|
// untouched by this task) runs against it, so `registerPredicate`'s module-load
|
|
// side effect (`registry.ts`'s real top-level call) still fires via `importOriginal`.
|
|
vi.mock('../../../src/systemNotices/registry', async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import('../../../src/systemNotices/registry')>();
|
|
return { ...actual, SYSTEM_NOTICES: [RELEASE_NOTICE, GENERIC_NOTICE, ADDON_NOTICE, CUSTOM_NOTICE] };
|
|
});
|
|
|
|
const BASE_USER = { id: 7, login_count: 5, first_seen_version: '3.0.0', role: 'user' } as UserRow;
|
|
|
|
interface StubOverrides {
|
|
findById?: UserRow | null;
|
|
countForUser?: number;
|
|
listForUser?: UserNoticeDismissalRow[];
|
|
isAddonEnabled?: (id: string) => boolean;
|
|
getValue?: string | null;
|
|
isManaged?: boolean;
|
|
}
|
|
|
|
function makeService(overrides: StubOverrides = {}) {
|
|
const findById = vi.fn(async () => (overrides.findById === undefined ? BASE_USER : overrides.findById));
|
|
const countForUser = vi.fn(async () => overrides.countForUser ?? 0);
|
|
const listForUser = vi.fn(async () => overrides.listForUser ?? []);
|
|
const upsertDismissal = vi.fn(
|
|
async (_userId: number, _noticeId: string, _dismissedAt: number, _dismissedAppVersion: string) => undefined,
|
|
);
|
|
const isAddonEnabled = vi.fn(async (id: string) => (overrides.isAddonEnabled ? overrides.isAddonEnabled(id) : false));
|
|
const getValue = vi.fn(async () => (overrides.getValue === undefined ? null : overrides.getValue));
|
|
const isManaged = vi.fn(() => overrides.isManaged ?? false);
|
|
|
|
const addons = { isAddonEnabled } as unknown as AddonsService;
|
|
const env = { isManaged } as unknown as RuntimeEnvService;
|
|
const users = { findById } as unknown as UsersRepository;
|
|
const trips = { countForUser } as unknown as TripsRepository;
|
|
const dismissals = { listForUser, upsertDismissal } as unknown as UserNoticeDismissalsRepository;
|
|
const appSettings = { getValue } as unknown as AppSettingsRepository;
|
|
|
|
const svc = new SystemNoticesService(addons, env, users, trips, dismissals, appSettings);
|
|
return { svc, findById, countForUser, listForUser, upsertDismissal, isAddonEnabled, getValue, isManaged };
|
|
}
|
|
|
|
describe('SystemNoticesService (Plan 3f Task 6)', () => {
|
|
describe('getActiveFor — SN2/SN3/SN4 wiring', () => {
|
|
it('SN2 — returns [] immediately when the user is absent, without reading trips/dismissals/settings', async () => {
|
|
const { svc, countForUser, listForUser, getValue } = makeService({ findById: null });
|
|
expect(await svc.getActiveFor(999)).toEqual([]);
|
|
expect(countForUser).not.toHaveBeenCalled();
|
|
expect(listForUser).not.toHaveBeenCalled();
|
|
expect(getValue).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('SN3 — threads noTrips off the injected TripsRepository.countForUser', async () => {
|
|
const { svc, countForUser } = makeService({ countForUser: 0 });
|
|
await svc.getActiveFor(BASE_USER.id);
|
|
expect(countForUser).toHaveBeenCalledWith(BASE_USER.id);
|
|
});
|
|
|
|
it('SN4 — a per-version-recurring notice re-shows once the running app version passes the dismissed one, and stays hidden while it has not', async () => {
|
|
const olderDismissal = makeService({
|
|
listForUser: [{ notice_id: 'sn-test-release', dismissed_app_version: '0.0.1' }],
|
|
});
|
|
expect(
|
|
(await olderDismissal.svc.getActiveFor(BASE_USER.id, new Set(['release']), getCurrentAppVersion())).map(
|
|
(n) => n.id,
|
|
),
|
|
).toContain('sn-test-release');
|
|
|
|
const currentDismissal = makeService({
|
|
listForUser: [{ notice_id: 'sn-test-release', dismissed_app_version: getCurrentAppVersion() }],
|
|
});
|
|
expect(
|
|
(await currentDismissal.svc.getActiveFor(BASE_USER.id, new Set(['release']), getCurrentAppVersion())).map(
|
|
(n) => n.id,
|
|
),
|
|
).not.toContain('sn-test-release');
|
|
});
|
|
|
|
it('a permanently-dismissed (non-recurring) notice never returns', async () => {
|
|
const { svc } = makeService({ listForUser: [{ notice_id: 'sn-test-generic', dismissed_app_version: null }] });
|
|
expect((await svc.getActiveFor(BASE_USER.id)).map((n) => n.id)).not.toContain('sn-test-generic');
|
|
});
|
|
});
|
|
|
|
describe('getActiveFor — addonEnabled threading (over the injected AddonsService)', () => {
|
|
it('resolves the addon flag once per call and gates the notice on it', async () => {
|
|
const on = makeService({ isAddonEnabled: (id) => id === 'journey' });
|
|
expect((await on.svc.getActiveFor(BASE_USER.id)).map((n) => n.id)).toContain('sn-test-addon');
|
|
expect(on.isAddonEnabled).toHaveBeenCalledWith('journey');
|
|
|
|
const off = makeService({ isAddonEnabled: () => false });
|
|
expect((await off.svc.getActiveFor(BASE_USER.id)).map((n) => n.id)).not.toContain('sn-test-addon');
|
|
});
|
|
});
|
|
|
|
describe('getActiveFor — settingFlag threading (SN1, the ConditionContext addition)', () => {
|
|
it("resolves 'whitespace_migration_collision' off the injected AppSettingsRepository and gates the custom-predicate notice on it, both directions", async () => {
|
|
const flagged = makeService({ getValue: 'true' });
|
|
expect((await flagged.svc.getActiveFor(BASE_USER.id)).map((n) => n.id)).toContain('sn-test-custom');
|
|
expect(flagged.getValue).toHaveBeenCalledWith('whitespace_migration_collision');
|
|
|
|
const clear = makeService({ getValue: 'false' });
|
|
expect((await clear.svc.getActiveFor(BASE_USER.id)).map((n) => n.id)).not.toContain('sn-test-custom');
|
|
|
|
const absent = makeService({ getValue: null });
|
|
expect((await absent.svc.getActiveFor(BASE_USER.id)).map((n) => n.id)).not.toContain('sn-test-custom');
|
|
});
|
|
});
|
|
|
|
// After an update the service worker keeps serving the previous bundle for a while.
|
|
// That bundle would draw a release notice as bare keys and let the reader dismiss it
|
|
// for good, so the release layout is only delivered to a client that says it can
|
|
// draw it. Everything without a release block goes out as before.
|
|
describe('getActiveFor holds the release layout back from a client that does not announce it', () => {
|
|
it('drops a notice with a release block when nothing is announced', async () => {
|
|
const { svc } = makeService();
|
|
expect((await svc.getActiveFor(BASE_USER.id)).map((n) => n.id)).toEqual(['sn-test-generic']);
|
|
});
|
|
|
|
it('drops it when other layouts are announced but not release', async () => {
|
|
const { svc } = makeService();
|
|
expect((await svc.getActiveFor(BASE_USER.id, new Set(['banner']))).map((n) => n.id)).toEqual(['sn-test-generic']);
|
|
});
|
|
|
|
it('delivers it, in place, once the client announces the release layout for the running version', async () => {
|
|
const { svc } = makeService();
|
|
const ids = (await svc.getActiveFor(BASE_USER.id, new Set(['release']), getCurrentAppVersion())).map((n) => n.id);
|
|
expect(ids).toContain('sn-test-release');
|
|
expect(ids).toContain('sn-test-generic');
|
|
});
|
|
|
|
it('drops it for a bundle that announces the layout but was built for another version', async () => {
|
|
const { svc } = makeService();
|
|
expect((await svc.getActiveFor(BASE_USER.id, new Set(['release']), '0.0.1')).map((n) => n.id)).toEqual([
|
|
'sn-test-generic',
|
|
]);
|
|
});
|
|
|
|
it('drops it for a bundle that names no version at all', async () => {
|
|
const { svc } = makeService();
|
|
expect((await svc.getActiveFor(BASE_USER.id, new Set(['release']))).map((n) => n.id)).toEqual([
|
|
'sn-test-generic',
|
|
]);
|
|
expect((await svc.getActiveFor(BASE_USER.id, new Set(['release']), '')).map((n) => n.id)).toEqual([
|
|
'sn-test-generic',
|
|
]);
|
|
expect((await svc.getActiveFor(BASE_USER.id, new Set(['release']), 'dev')).map((n) => n.id)).toEqual([
|
|
'sn-test-generic',
|
|
]);
|
|
});
|
|
|
|
// L3 (task-7-review.md): dropped by Task 6, restored — the running app
|
|
// version is read loosely (`semver.coerce`-shaped, tolerating a leading
|
|
// `v`), same as the server reads its own.
|
|
it('reads the version loosely, as the server reads its own', async () => {
|
|
const { svc } = makeService();
|
|
const ids = (await svc.getActiveFor(BASE_USER.id, new Set(['release']), 'v' + getCurrentAppVersion())).map(
|
|
(n) => n.id,
|
|
);
|
|
expect(ids).toContain('sn-test-release');
|
|
expect(ids).toContain('sn-test-generic');
|
|
});
|
|
|
|
it('always delivers a notice without a release block', async () => {
|
|
const { svc } = makeService();
|
|
expect((await svc.getActiveFor(BASE_USER.id)).map((n) => n.id)).toEqual(['sn-test-generic']);
|
|
expect(
|
|
(await svc.getActiveFor(BASE_USER.id, new Set(['release']), getCurrentAppVersion())).map((n) => n.id),
|
|
).toContain('sn-test-generic');
|
|
});
|
|
});
|
|
|
|
// L3 (task-7-review.md): dropped by Task 6, restored. The emitted
|
|
// design:paramtypes metadata guards an unresolved class binding with
|
|
// `typeof AddonsService === 'undefined' ? Object : AddonsService`.
|
|
// Simulate that mid-cycle state: the module must still evaluate and the
|
|
// instance must still serve notices.
|
|
it('still loads and works when the AddonsService binding is unresolved (import-cycle fallback)', async () => {
|
|
vi.resetModules();
|
|
vi.doMock('../../../src/nest/addons/addons.service', () => ({ AddonsService: undefined }));
|
|
const { SystemNoticesService: Reloaded } = await import('../../../src/nest/system-notices/system-notices.service');
|
|
const isAddonEnabled = vi.fn(async () => false);
|
|
const inst = new Reloaded(
|
|
{ isAddonEnabled } as never,
|
|
{ isManaged: () => false } as never,
|
|
{ findById: async () => BASE_USER } as never,
|
|
{ countForUser: async () => 1 } as never,
|
|
{ listForUser: async () => [] } as never,
|
|
{ getValue: async () => null } as never,
|
|
);
|
|
const out = await inst.getActiveFor(BASE_USER.id);
|
|
expect(out.map((n) => n.id)).toEqual(['sn-test-generic']);
|
|
vi.doUnmock('../../../src/nest/addons/addons.service');
|
|
});
|
|
|
|
describe('dismiss', () => {
|
|
it('SN5 — upserts through the composite-PK repository with Date.now()/getCurrentAppVersion(), and returns true', async () => {
|
|
const { svc, upsertDismissal } = makeService();
|
|
const before = Date.now();
|
|
expect(await svc.dismiss(BASE_USER.id, 'sn-test-generic')).toBe(true);
|
|
expect(upsertDismissal).toHaveBeenCalledTimes(1);
|
|
const [userId, noticeId, dismissedAt, appVersion] = upsertDismissal.mock.calls[0];
|
|
expect(userId).toBe(BASE_USER.id);
|
|
expect(noticeId).toBe('sn-test-generic');
|
|
expect(dismissedAt).toBeGreaterThanOrEqual(before);
|
|
expect(dismissedAt).toBeLessThanOrEqual(Date.now());
|
|
expect(appVersion).toBe(getCurrentAppVersion());
|
|
});
|
|
|
|
it('returns false for an unknown notice id, without touching the repository', async () => {
|
|
const { svc, upsertDismissal } = makeService();
|
|
expect(await svc.dismiss(BASE_USER.id, 'does-not-exist')).toBe(false);
|
|
expect(upsertDismissal).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
});
|