refactor(system-notices): notice dismissals through a Nest provider and repository

This commit is contained in:
jubnl
2026-09-23 21:30:44 +02:00
parent 8b94a5a03e
commit 2f1bb2597e
10 changed files with 577 additions and 251 deletions
+35 -54
View File
@@ -1,8 +1,20 @@
/**
* System-notices module e2e — exercises the migrated /api/system-notices
* endpoints through the real JwtAuthGuard against a temp SQLite db. The notices
* service is mocked so the test doesn't depend on the static registry or the
* dismissal tables; it focuses on routing, auth, status codes and bodies.
* endpoints through the real JwtAuthGuard against a temp SQLite db. Focuses on
* routing, auth, status codes and bodies, per its original scope.
*
* Plan 3f Task 6: `getActiveNoticesFor`/`dismissNotice` (formerly a separately
* mockable plain module, `../../src/systemNotices/service.ts`) are now private
* methods absorbed into `SystemNoticesService` itself, so there is no longer a
* seam below the Nest provider to mock — `SystemNoticesService` is overridden
* as a whole via Nest's `overrideProvider`, the same boundary
* `system-notices.controller.test.ts` already mocks at. The release-layout
* gating this test used to exercise through that lower seam (with a real
* `SystemNoticesService` calling a mocked `getActiveNoticesFor`) is proven
* directly against the real class in `system-notices.service.test.ts` instead,
* now that the gating logic and the notice list it filters live in the one
* class this file would otherwise have to fully re-implement to keep testing
* through HTTP.
*/
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
import request from 'supertest';
@@ -23,14 +35,8 @@ const { db } = vi.hoisted(() => {
vi.mock('../../src/db/database', () => ({ db, closeDb: () => {}, reinitialize: () => {} }));
const { mockGetActive, mockDismiss } = vi.hoisted(() => ({ mockGetActive: vi.fn(), mockDismiss: vi.fn() }));
vi.mock('../../src/systemNotices/service', () => ({
getActiveNoticesFor: mockGetActive,
dismissNotice: mockDismiss,
getCurrentAppVersion: () => '4.3.0',
}));
import { SystemNoticesModule } from '../../src/nest/system-notices/system-notices.module';
import { SystemNoticesService } from '../../src/nest/system-notices/system-notices.service';
import { DatabaseModule } from '../../src/nest/database/database.module';
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
import { TestUnitOfWorkModule } from '../helpers/test-uow';
@@ -44,14 +50,21 @@ const notice = {
describe('System-notices e2e (real auth guard + temp SQLite)', () => {
let server: Server;
let app: Awaited<ReturnType<typeof build>>;
const getActiveFor = vi.fn().mockResolvedValue([]);
const dismiss = vi.fn().mockResolvedValue(true);
async function build() {
// DatabaseModule is @Global in the real app; a partial graph has to
// provide it for SystemNoticesModule's AddonsModule import (the
// addons.e2e precedent). createTestMikroOrmModule for the same import's
// MikroOrmModule.forFeature (Plan 3a Task 4) — no case here calls an
// AddonsService method, so the minimal `users`-only schema above is enough.
const moduleRef = await Test.createTestingModule({ imports: [await TestUnitOfWorkModule.forRoot(db), await createTestMikroOrmModule(db), DatabaseModule, SystemNoticesModule] }).compile();
// MikroOrmModule.forFeature (Plan 3a Task 4) and for SystemNoticesModule's
// own MikroOrmModule.forFeature (Plan 3f Task 6) — no case here reaches a
// real repository (SystemNoticesService is overridden below), so the
// minimal `users`-only schema above is enough.
const moduleRef = await Test.createTestingModule({ imports: [await TestUnitOfWorkModule.forRoot(db), await createTestMikroOrmModule(db), DatabaseModule, SystemNoticesModule] })
.overrideProvider(SystemNoticesService)
.useValue({ getActiveFor, dismiss })
.compile();
const nest = moduleRef.createNestApplication();
nest.use(cookieParser());
nest.useGlobalFilters(new TrekExceptionFilter());
@@ -76,62 +89,30 @@ describe('System-notices e2e (real auth guard + temp SQLite)', () => {
});
it('200 with the active notices for the user', async () => {
mockGetActive.mockReturnValue([notice]);
getActiveFor.mockResolvedValueOnce([notice]);
const res = await request(server).get('/api/system-notices/active').set('Cookie', sessionCookie(1));
expect(res.status).toBe(200);
expect(res.body).toEqual([notice]);
expect(mockGetActive).toHaveBeenCalledWith(1, expect.any(Function), false);
expect(getActiveFor).toHaveBeenCalledWith(1, new Set(), undefined);
});
// A bundle from before the release layout never sends `?supports=`, and it would
// draw the release notice as bare keys. The route keeps that notice for a client
// that announces the layout and serves everything else to both.
describe('the release layout and ?supports=', () => {
const release = {
...notice,
id: 'release-notes',
release: { version: '4.3.0', headlineKey: 'system_notice.release_notes.headline' },
};
it('holds the release notice back when the parameter is missing', async () => {
mockGetActive.mockReturnValue([release, notice]);
const res = await request(server).get('/api/system-notices/active').set('Cookie', sessionCookie(1));
expect(res.status).toBe(200);
expect(res.body).toEqual([notice]);
});
it('holds it back from a bundle that announces the layout but was built for another version', async () => {
mockGetActive.mockReturnValue([release, notice]);
const res = await request(server)
.get('/api/system-notices/active')
.query({ supports: 'release', ui: '4.2.1' })
.set('Cookie', sessionCookie(1));
expect(res.status).toBe(200);
expect(res.body).toEqual([notice]);
});
it('delivers the release notice to a client that announces the layout for the running version', async () => {
mockGetActive.mockReturnValue([release, notice]);
const res = await request(server)
.get('/api/system-notices/active')
.query({ supports: 'release', ui: '4.3.0' })
.set('Cookie', sessionCookie(1));
expect(res.status).toBe(200);
expect(res.body).toEqual([release, notice]);
});
it('passes ?supports=/?ui= through as the controller\'s own parsed arguments', async () => {
getActiveFor.mockResolvedValueOnce([]);
await request(server).get('/api/system-notices/active').query({ supports: 'release,banner', ui: '4.3.0' }).set('Cookie', sessionCookie(1));
expect(getActiveFor).toHaveBeenLastCalledWith(1, new Set(['release', 'banner']), '4.3.0');
});
it('204 with no body on a successful dismiss', async () => {
mockDismiss.mockReturnValue(true);
dismiss.mockResolvedValueOnce(true);
const res = await request(server).post('/api/system-notices/welcome/dismiss').set('Cookie', sessionCookie(1));
expect(res.status).toBe(204);
expect(res.body).toEqual({});
expect(res.text).toBe('');
expect(mockDismiss).toHaveBeenCalledWith(1, 'welcome');
expect(dismiss).toHaveBeenCalledWith(1, 'welcome');
});
it('404 { error: NOTICE_NOT_FOUND } when the id is unknown', async () => {
mockDismiss.mockReturnValue(false);
dismiss.mockResolvedValueOnce(false);
const res = await request(server).post('/api/system-notices/nope/dismiss').set('Cookie', sessionCookie(1));
expect(res.status).toBe(404);
expect(res.body).toEqual({ error: 'NOTICE_NOT_FOUND' });
@@ -0,0 +1,114 @@
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { createSnapshotTestDb } from '../../../helpers/db-mock';
import { resetTestDb } from '../../../helpers/test-db';
import { createTestOrm, type TestOrm } from '../../../helpers/test-orm';
import { createUser, type TestUser } from '../../../helpers/factories';
import { UserNoticeDismissals } from '../../../../src/db/entities/UserNoticeDismissals.entity';
import type { UserNoticeDismissalsRepository } from '../../../../src/db/repositories/UserNoticeDismissals.repository';
const testDb = createSnapshotTestDb();
let t: TestOrm;
let repo: UserNoticeDismissalsRepository;
let user: TestUser;
let otherUser: TestUser;
beforeAll(async () => {
t = await createTestOrm(testDb);
repo = t.repo(UserNoticeDismissals);
});
beforeEach(() => {
resetTestDb(testDb);
t.clear();
user = createUser(testDb).user;
otherUser = createUser(testDb).user;
});
afterAll(async () => {
await t.close();
testDb.close();
});
function insertRaw(userId: number, noticeId: string, dismissedAt: number, dismissedAppVersion: string | null): void {
testDb.prepare('INSERT INTO user_notice_dismissals (user_id, notice_id, dismissed_at, dismissed_app_version) VALUES (?, ?, ?, ?)').run(userId, noticeId, dismissedAt, dismissedAppVersion);
}
describe('user_notice_dismissals — the (user_id, notice_id) composite PK the ON CONFLICT target relies on (Plan 3f Task 0 R5/§14, Task 6)', () => {
it("UNDREPO-SCHEMA-001: PRAGMA table_info('user_notice_dismissals') reports a 2-column PRIMARY KEY over exactly (user_id, notice_id), matching the migration DDL", () => {
const cols = testDb.prepare("PRAGMA table_info('user_notice_dismissals')").all() as { name: string; pk: number }[];
const pkCols = cols.filter((c) => c.pk > 0).sort((a, b) => a.pk - b.pk).map((c) => c.name);
expect(pkCols).toEqual(['user_id', 'notice_id']);
});
it('UNDREPO-SCHEMA-002: the entity metadata declares the same 2-property composite PK, naming the RELATION property, not its persist(false) twin', () => {
const meta = t.orm.getMetadata(UserNoticeDismissals);
expect(meta.getPrimaryProps().map((p) => p.name).sort()).toEqual(['notice_id', 'user'].sort());
});
});
describe('UserNoticeDismissalsRepository — reads (SN4 parity)', () => {
it('UNDREPO-001 — listForUser matches `SELECT notice_id, dismissed_app_version FROM user_notice_dismissals WHERE user_id = ?`, fully seeded across several notices/versions, and never leaks another user', async () => {
insertRaw(user.id, 'welcome-v1', 1_700_000_000_000, null);
insertRaw(user.id, 'release-notes', 1_700_000_001_000, '4.2.1');
insertRaw(user.id, 'v3014-whitespace-collision', 1_700_000_002_000, '3.0.14');
insertRaw(otherUser.id, 'welcome-v1', 1_700_000_003_000, '4.3.0'); // a different user's row — must not leak in
const legacy = (testDb.prepare('SELECT notice_id, dismissed_app_version FROM user_notice_dismissals WHERE user_id = ?').all(user.id) as { notice_id: string; dismissed_app_version: string | null }[])
.sort((a, b) => a.notice_id.localeCompare(b.notice_id));
const converted = (await repo.listForUser(user.id))
.map((r) => ({ notice_id: r.notice_id, dismissed_app_version: r.dismissed_app_version }))
.sort((a, b) => a.notice_id.localeCompare(b.notice_id));
expect(converted.length).toBe(3);
expect(converted).toEqual(legacy);
// Rule 16: a NULL `dismissed_app_version` stays `null` on the row, never `undefined`.
expect(converted.find((r) => r.notice_id === 'welcome-v1')?.dismissed_app_version).toBeNull();
});
it('UNDREPO-002 — listForUser is empty for a user with no dismissals', async () => {
expect(await repo.listForUser(user.id)).toEqual([]);
});
});
describe('UserNoticeDismissalsRepository — writes (SN5, R5)', () => {
it(
"UNDREPO-UPSERT-SQL: em.upsert renders `insert into \"user_notice_dismissals\" ... on conflict (`user_id`, `notice_id`) do update set ...` — the composite-PK conflict target Task 0's R5 worked example pinned (re-checked against Task 3's `NotificationChannelPreferences.repository.test.ts` NCPREPO-UPSERT-SQL pin, not re-derived), checked directly against the rendered SQL, not assumed from onConflictFields alone",
async () => {
const connection = t.orm.em.getConnection();
const spy = vi.spyOn(connection, 'execute');
try {
// Insert branch: no existing row.
await repo.upsertDismissal(user.id, 'release-notes', 1_700_000_000_000, '4.2.1');
expect(testDb.prepare('SELECT dismissed_at, dismissed_app_version FROM user_notice_dismissals WHERE user_id = ? AND notice_id = ?').get(user.id, 'release-notes'))
.toEqual({ dismissed_at: 1_700_000_000_000, dismissed_app_version: '4.2.1' });
const insertSql = spy.mock.calls.map(([sql]) => sql).find((sql): sql is string => typeof sql === 'string' && /insert into/i.test(sql));
expect(insertSql).toContain('on conflict (`user_id`, `notice_id`)');
spy.mockClear();
// Merge branch: re-dismissing after a version bump — SAME conflict target, updates
// the existing row in place rather than inserting a second one (the composite PK
// is what makes this an UPDATE, not an INSERT, on the second call).
await repo.upsertDismissal(user.id, 'release-notes', 1_700_000_050_000, '4.3.0');
expect(testDb.prepare('SELECT dismissed_at, dismissed_app_version FROM user_notice_dismissals WHERE user_id = ? AND notice_id = ?').get(user.id, 'release-notes'))
.toEqual({ dismissed_at: 1_700_000_050_000, dismissed_app_version: '4.3.0' });
expect((testDb.prepare('SELECT COUNT(*) as c FROM user_notice_dismissals WHERE user_id = ? AND notice_id = ?').get(user.id, 'release-notes') as { c: number }).c).toBe(1);
const mergeSql = spy.mock.calls.map(([sql]) => sql).find((sql): sql is string => typeof sql === 'string' && /insert into/i.test(sql));
expect(mergeSql).toContain('on conflict (`user_id`, `notice_id`)');
} finally {
spy.mockRestore();
}
},
);
it('UNDREPO-003 — upsertDismissal scopes to exactly (user, notice) — a different user or a different notice for the same user is untouched', async () => {
await repo.upsertDismissal(user.id, 'release-notes', 1_700_000_000_000, '4.2.1');
await repo.upsertDismissal(user.id, 'welcome-v1', 1_700_000_001_000, '4.0.0');
await repo.upsertDismissal(otherUser.id, 'release-notes', 1_700_000_002_000, '4.1.0');
expect((testDb.prepare('SELECT COUNT(*) as c FROM user_notice_dismissals').get() as { c: number }).c).toBe(3);
expect(testDb.prepare('SELECT dismissed_app_version FROM user_notice_dismissals WHERE user_id = ? AND notice_id = ?').get(user.id, 'release-notes'))
.toEqual({ dismissed_app_version: '4.2.1' });
expect(testDb.prepare('SELECT dismissed_app_version FROM user_notice_dismissals WHERE user_id = ? AND notice_id = ?').get(otherUser.id, 'release-notes'))
.toEqual({ dismissed_app_version: '4.1.0' });
});
});
@@ -1,48 +1,152 @@
/**
* SystemNoticesService — the DI wrapper over the plain systemNotices service.
* What's under test is the threading added when conditions.ts lost its
* addons.bridge import: getActiveFor must hand the evaluator a live
* addonEnabled callback over the INJECTED AddonsService. The upstream plain
* service is mocked; its own behavior is pinned in tests/unit/systemNotices/
* and the integration suite.
* 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 { describe, it, expect, vi, beforeEach } from 'vitest';
import { describe, it, expect, vi } from 'vitest';
import type { SystemNotice } from '../../../src/systemNotices/types';
const { mockGetActive, mockDismiss, mockAppVersion } = vi.hoisted(() => ({ mockGetActive: vi.fn(), mockDismiss: vi.fn(), mockAppVersion: vi.fn(() => '4.3.0') }));
vi.mock('../../../src/systemNotices/service', () => ({
getActiveNoticesFor: mockGetActive,
dismissNotice: mockDismiss,
getCurrentAppVersion: mockAppVersion,
}));
import { SystemNoticesService } from '../../../src/nest/system-notices/system-notices.service';
import type { AddonsService } from '../../../src/nest/addons/addons.service';
import type { RuntimeEnvService } from '../../../src/nest/app-config/runtime-env.service';
const isAddonEnabled = vi.fn((id: string) => id === 'journey');
const svc = new SystemNoticesService({ isAddonEnabled } as unknown as AddonsService, { isManaged: () => false } as unknown as RuntimeEnvService);
beforeEach(() => {
mockGetActive.mockReset();
mockDismiss.mockReset();
isAddonEnabled.mockClear();
// `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 };
});
describe('SystemNoticesService', () => {
it('getActiveFor threads a live addonEnabled check over the injected AddonsService', async () => {
mockGetActive.mockReturnValue([]);
await svc.getActiveFor(7);
expect(mockGetActive).toHaveBeenCalledWith(7, expect.any(Function), false);
const addonEnabled = mockGetActive.mock.calls[0][1] as (id: string) => boolean;
expect(addonEnabled('journey')).toBe(true);
expect(addonEnabled('vacay')).toBe(false);
expect(isAddonEnabled).toHaveBeenLastCalledWith('vacay');
// 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] };
});
import { SystemNoticesService } from '../../../src/nest/system-notices/system-notices.service';
import { getCurrentAppVersion } from '../../../src/systemNotices/service';
import type { AddonsService } from '../../../src/nest/addons/addons.service';
import type { RuntimeEnvService } from '../../../src/nest/app-config/runtime-env.service';
import type { UsersRepository, UserRow } from '../../../src/db/repositories/Users.repository';
import type { TripsRepository } from '../../../src/db/repositories/Trips.repository';
import type { UserNoticeDismissalsRepository, UserNoticeDismissalRow } from '../../../src/db/repositories/UserNoticeDismissals.repository';
import type { AppSettingsRepository } from '../../../src/db/repositories/AppSettings.repository';
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');
});
});
it('dismiss passes through to the upstream service', async () => {
mockDismiss.mockReturnValue(true);
expect(await svc.dismiss(7, 'welcome')).toBe(true);
expect(mockDismiss).toHaveBeenCalledWith(7, 'welcome');
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.
@@ -50,73 +154,60 @@ describe('SystemNoticesService', () => {
// 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', () => {
const generic = {
id: 'outage', display: 'banner', severity: 'warn',
titleKey: 'system_notice.outage.title', bodyKey: 'system_notice.outage.body', dismissible: true,
};
const release = {
id: 'release-notes', display: 'modal', severity: 'info',
titleKey: 'system_notice.release_notes.headline', bodyKey: 'system_notice.release_notes.intro',
dismissible: true,
release: { version: '4.3.0', headlineKey: 'system_notice.release_notes.headline' },
};
it('drops a notice with a release block when nothing is announced', async () => {
mockGetActive.mockReturnValue([release, generic]);
expect((await svc.getActiveFor(7)).map(n => n.id)).toEqual(['outage']);
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 () => {
mockGetActive.mockReturnValue([release, generic]);
expect((await svc.getActiveFor(7, new Set(['banner']))).map(n => n.id)).toEqual(['outage']);
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 () => {
mockGetActive.mockReturnValue([release, generic]);
expect((await svc.getActiveFor(7, new Set(['release']), '4.3.0')).map(n => n.id)).toEqual(['release-notes', 'outage']);
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 () => {
// The shell the service worker serves right after an update: it can draw the
// layout, but with the texts of the version it was built for, and its X would
// use the notice up for the version now running.
mockGetActive.mockReturnValue([release, generic]);
expect((await svc.getActiveFor(7, new Set(['release']), '4.2.1')).map(n => n.id)).toEqual(['outage']);
mockAppVersion.mockReturnValueOnce('4.3.1');
expect((await svc.getActiveFor(7, new Set(['release']), '4.3.0')).map(n => n.id)).toEqual(['outage']);
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 () => {
mockGetActive.mockReturnValue([release, generic]);
expect((await svc.getActiveFor(7, new Set(['release']))).map(n => n.id)).toEqual(['outage']);
expect((await svc.getActiveFor(7, new Set(['release']), '')).map(n => n.id)).toEqual(['outage']);
expect((await svc.getActiveFor(7, new Set(['release']), 'dev')).map(n => n.id)).toEqual(['outage']);
});
it('reads the version loosely, as the server reads its own', async () => {
mockGetActive.mockReturnValue([release, generic]);
expect((await svc.getActiveFor(7, new Set(['release']), 'v4.3.0')).map(n => n.id)).toEqual(['release-notes', 'outage']);
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']);
});
it('always delivers a notice without a release block', async () => {
mockGetActive.mockReturnValue([generic]);
expect((await svc.getActiveFor(7)).map(n => n.id)).toEqual(['outage']);
expect((await svc.getActiveFor(7, new Set(['release']), '4.3.0')).map(n => n.id)).toEqual(['outage']);
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');
});
});
it('still loads and works when the AddonsService binding is unresolved (import-cycle fallback)', async () => {
// 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 thread the callback.
vi.resetModules();
vi.doMock('../../../src/nest/addons/addons.service', () => ({ AddonsService: undefined }));
const { SystemNoticesService: Reloaded } = await import('../../../src/nest/system-notices/system-notices.service');
const inst = new Reloaded({ isAddonEnabled } as unknown as AddonsService, { isManaged: () => false } as unknown as RuntimeEnvService);
mockGetActive.mockReturnValue([]);
await inst.getActiveFor(1);
expect(mockGetActive).toHaveBeenCalledWith(1, expect.any(Function), false);
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();
});
});
});
@@ -31,6 +31,11 @@ const baseCtx = {
// The ordinary install, so every case below is unaffected by the flag; the
// managed condition itself is exercised in its own describe block at the end.
managed: false,
// Plan 3f Task 6: threaded in by the caller the same way addonEnabled is,
// for a `case 'custom'` predicate that needs a pre-resolved DB-backed flag
// (registry.ts's whitespace-collision-detected). No case below exercises a
// custom predicate, so this stays false throughout.
settingFlag: () => false,
};
describe('firstLogin', () => {