Files
TREK/server/tests/unit/nest/admin.version-notification.test.ts
T
jubnl 2ac6c76086 chore: move admin backend to proper DI
The 851-line services/adminService folds into the wrapper AdminService over
DatabaseService plus injected Settings/Addons/Passkey/Packing/Auth/Permissions/
Notifications services; the auth, notifications and permissions bridge imports
all become injections, PERMISSION_ACTIONS stays a plain const, and the
mcp/sessionManager deep import keeps its anti-cycle comment. Ahead of the fold
the 11 packing-template functions move to PackingService, which already owned all
three template tables — resolving the admin-2 residual with no bridge. The pure
and module-scoped half moves to admin.helpers.ts, the version cache staying
module-scoped so admin.bridge's instance and the container singleton share one
GitHub fetch. A 1-export admin.bridge.ts serves scheduler.ts's cron, the only
out-of-container consumer. Recipe steps 3 and 4 are no-ops: no src/mcp/tools/
admin.ts has ever existed and the plugin host never imported the domain.

Four lines are non-verbatim, all path re-anchoring one directory deeper, both
resolved paths verified against the emitted dist layout.

admin.dto.ts clears all twenty AdminController allow-list entries, retiring the
three 'enabled must be a boolean' checks plus 'permissions object required' and
'Object body required' for the pipe envelope. The module e2e goes DI-native: the
3-method whole-module mock dies, 6 cases become 15 over real SQL.

Quirk fixes on top, each pinned: the three places toggles go fail-closed with an
append-only backfill migration; the template item routes honour :templateId;
updateUser rejects an empty username/email; createInvite 404s an unresolvable
trip_id; listOAuthSessions guards its scopes parse; GitHub fetches gain a timeout
and size cap and failures cache briefly; updateOidcSettings/updateAddon/updateUser
become transactional; admin.oauth_session.revoke is renamed to match its
siblings; two mutations gain their missing audit rows; getAuditLog drops the
_parse_error sentinel.

Tests move with IDs preserved (ADMIN-SVC-001..069 incl. the pre-existing 029/030
gap and duplicated 069, VNOTIF-001..007), plus ADMIN-BR-001 and ADMIN-SVC-070..076.
2026-08-02 15:07:55 +02:00

223 lines
8.4 KiB
TypeScript

/**
* Unit tests for AdminService.checkAndNotifyVersion() — VNOTIF-001 to
* VNOTIF-007, moved from tests/unit/services/versionNotification.test.ts with
* the 2026-08 admin fold, IDs preserved. Kept separate from admin.service.test.ts
* because it stubs global fetch and drives the module-scoped version cache per
* case, which would leak into the ADMIN-SVC-* suite. The notification path runs
* for real against the temp db's notifications table.
*/
import { runMigrations } from '../../../src/db/migrations';
import { createTables } from '../../../src/db/schema';
import { __clearVersionCacheForTests } from '../../../src/nest/admin/admin.helpers';
import { createAdmin } from '../../helpers/factories';
import { resetTestDb } from '../../helpers/test-db';
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
const { testDb, dbMock } = vi.hoisted(() => {
const Database = require('better-sqlite3');
const db = new Database(':memory:');
db.exec('PRAGMA journal_mode = WAL');
db.exec('PRAGMA foreign_keys = ON');
const mock = {
db,
closeDb: () => {},
reinitialize: () => {},
getPlaceWithTags: () => null,
canAccessTrip: () => null,
isOwner: () => false,
};
return { testDb: db, dbMock: mock };
});
vi.mock('../../../src/db/database', () => dbMock);
vi.mock('../../../src/config', () => ({
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
updateJwtSecret: () => {},
}));
vi.mock('../../../src/websocket', () => ({ broadcastToUser: vi.fn() }));
// Mock MCP to avoid session side-effects
vi.mock('../../../src/mcp', () => ({ revokeUserSessions: vi.fn(), invalidateMcpSessions: vi.fn() }));
vi.mock('../../../src/mcp/sessionManager', () => ({ revokeUserSessions: vi.fn(), revokeUserSessionsForClient: vi.fn() }));
import { DatabaseService } from '../../../src/nest/database/database.service';
import { RealtimeService } from '../../../src/nest/realtime/realtime.service';
import { AddonsService } from '../../../src/nest/addons/addons.service';
import { SettingsService } from '../../../src/nest/settings/settings.service';
import { AtlasService } from '../../../src/nest/atlas/atlas.service';
import { AuthService } from '../../../src/nest/auth/auth.service';
import { PasskeyService } from '../../../src/nest/auth/passkey.service';
import { PackingService } from '../../../src/nest/packing/packing.service';
import { PermissionsService } from '../../../src/nest/permissions/permissions.service';
import { NotificationsService } from '../../../src/nest/notifications/notifications.service';
import { AdminService } from '../../../src/nest/admin/admin.service';
const dbs = new DatabaseService(testDb);
const realtime = new RealtimeService();
const permissions = new PermissionsService(dbs);
const auth = new AuthService(dbs, permissions, new AtlasService(dbs));
const svc = new AdminService(
dbs,
new SettingsService(dbs),
new AddonsService(dbs),
new PasskeyService(dbs, auth),
new PackingService(dbs, permissions, realtime),
auth,
permissions,
new NotificationsService(dbs, realtime),
);
const checkAndNotifyVersion = () => svc.checkAndNotifyVersion();
// Helper: mock the GitHub releases/latest endpoint
function mockGitHubLatest(tagName: string, ok = true): void {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok,
// fetchGithub reads text() and parses it itself (size cap), so stub both.
text: async () => JSON.stringify({ tag_name: tagName, html_url: `https://github.com/liketrek/TREK/releases/tag/${tagName}` }),
json: async () => ({ tag_name: tagName, html_url: `https://github.com/liketrek/TREK/releases/tag/${tagName}` }),
}),
);
}
function mockGitHubFetchFailure(): void {
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Network error')));
}
function getLastNotifiedVersion(): string | undefined {
return (
testDb.prepare('SELECT value FROM app_settings WHERE key = ?').get('last_notified_version') as
| { value: string }
| undefined
)?.value;
}
function getNotificationCount(): number {
return (testDb.prepare('SELECT COUNT(*) as c FROM notifications').get() as { c: number }).c;
}
beforeAll(() => {
createTables(testDb);
runMigrations(testDb);
});
beforeEach(() => {
resetTestDb(testDb);
__clearVersionCacheForTests();
vi.unstubAllGlobals();
});
afterAll(() => {
testDb.close();
vi.unstubAllGlobals();
});
// ─────────────────────────────────────────────────────────────────────────────
// checkAndNotifyVersion
// ─────────────────────────────────────────────────────────────────────────────
describe('checkAndNotifyVersion', () => {
it('VNOTIF-001 — does nothing when no update is available', async () => {
createAdmin(testDb);
// GitHub reports same version as package.json (or older) → update_available: false
const { version } = require('../../../package.json');
mockGitHubLatest(`v${version}`);
await checkAndNotifyVersion();
expect(getNotificationCount()).toBe(0);
expect(getLastNotifiedVersion()).toBeUndefined();
});
it('VNOTIF-002 — creates a navigate notification for all admins when update available', async () => {
const { user: admin1 } = createAdmin(testDb);
const { user: admin2 } = createAdmin(testDb);
mockGitHubLatest('v99.0.0');
await checkAndNotifyVersion();
const notifications = testDb.prepare('SELECT * FROM notifications ORDER BY id').all() as Array<{
recipient_id: number;
type: string;
scope: string;
}>;
expect(notifications.length).toBe(2);
const recipientIds = notifications.map((n) => n.recipient_id);
expect(recipientIds).toContain(admin1.id);
expect(recipientIds).toContain(admin2.id);
expect(notifications[0].type).toBe('navigate');
expect(notifications[0].scope).toBe('admin');
});
it('VNOTIF-003 — sets last_notified_version in app_settings after notifying', async () => {
createAdmin(testDb);
mockGitHubLatest('v99.1.0');
await checkAndNotifyVersion();
expect(getLastNotifiedVersion()).toBe('99.1.0');
});
it('VNOTIF-004 — does NOT create duplicate notification if last_notified_version matches', async () => {
createAdmin(testDb);
mockGitHubLatest('v99.2.0');
// First call notifies
await checkAndNotifyVersion();
const countAfterFirst = getNotificationCount();
expect(countAfterFirst).toBe(1);
// Second call with same version — should not create another
await checkAndNotifyVersion();
expect(getNotificationCount()).toBe(countAfterFirst);
});
it('VNOTIF-005 — creates new notification when last_notified_version is an older version', async () => {
createAdmin(testDb);
// Simulate having been notified about an older version
testDb
.prepare('INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)')
.run('last_notified_version', '98.0.0');
mockGitHubLatest('v99.3.0');
await checkAndNotifyVersion();
expect(getNotificationCount()).toBe(1);
expect(getLastNotifiedVersion()).toBe('99.3.0');
});
it('VNOTIF-006 — notification has correct type, scope, and navigate_target', async () => {
createAdmin(testDb);
mockGitHubLatest('v99.4.0');
await checkAndNotifyVersion();
const notif = testDb.prepare('SELECT * FROM notifications LIMIT 1').get() as {
type: string;
scope: string;
navigate_target: string;
title_key: string;
text_key: string;
navigate_text_key: string;
};
expect(notif.type).toBe('navigate');
expect(notif.scope).toBe('admin');
expect(notif.navigate_target).toBe('/admin');
expect(notif.title_key).toBe('notif.version_available.title');
expect(notif.text_key).toBe('notif.version_available.text');
expect(notif.navigate_text_key).toBe('notif.action.view_admin');
});
it('VNOTIF-007 — silently handles GitHub API fetch failure (no crash, no notification)', async () => {
createAdmin(testDb);
mockGitHubFetchFailure();
// Should not throw
await expect(checkAndNotifyVersion()).resolves.toBeUndefined();
expect(getNotificationCount()).toBe(0);
expect(getLastNotifiedVersion()).toBeUndefined();
});
});