/** * Unit tests for the DI-native SettingsService — SET-SVC-001 through * SET-SVC-034 (001–026 moved 1:1 from the legacy * tests/unit/services/settingsService.test.ts; 027–030 pin the post-migration * quirk fixes: null-serializes-as-'' and the bulk masked-sentinel skip; * 031–034 cover the CARTO basemap key). * Uses a real in-memory SQLite DB; apiKeyCrypto is mocked to a passthrough * so we don't need real encryption for most tests. */ import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest'; // ── DB + apiKeyCrypto mock ──────────────────────────────────────────────────── vi.mock('../../../src/db/database', async () => { const { createSnapshotTestDb } = await import('../../helpers/db-mock'); const db = createSnapshotTestDb(); const mock = { db, closeDb: () => {}, reinitialize: () => {}, getPlaceWithTags: () => null, canAccessTrip: () => null, isOwner: () => false, }; return mock; }); vi.mock('../../../src/config', () => ({ JWT_SECRET: 'test-secret', ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2', updateJwtSecret: () => {}, })); // Passthrough crypto — value comes back unchanged for most tests vi.mock('../../../src/nest/common/crypto/apiKeyCrypto', () => ({ maybe_encrypt_api_key: (v: string) => v, decrypt_api_key: (v: string) => v, })); import { db as testDb } from '../../../src/db/database'; import { resetTestDb } from '../../helpers/test-db'; import { createUser } from '../../helpers/factories'; import type { TestOrm } from '../../helpers/test-orm'; import { SettingsService } from '../../../src/nest/settings/settings.service'; import { sharedTestOrm, createTestUnitOfWork, createTestAppSettingsRepo, createTestSettingsRepo } from '../../helpers/test-uow'; let svc: SettingsService; let t: TestOrm; // `t`, `uow` and the two repositories all derive from the SAME `sharedTestOrm(testDb)` // (task-2-review.md I2): a second, independent `createTestOrm(testDb)` here would give // `svc`'s repositories a different Kysely client than the one `uow.transactional(...)` // opens its transaction on — a repository write inside setAdminUserDefaults/ // bulkUpsertSettings's transaction would then run outside it (or contend on a second // mutex over the same connection), exactly the trap the shared-ORM helpers exist to avoid. beforeAll(async () => { t = await sharedTestOrm(testDb); svc = new SettingsService( await createTestUnitOfWork(testDb), await createTestAppSettingsRepo(testDb), await createTestSettingsRepo(testDb), ); }); beforeEach(() => { resetTestDb(testDb); t.clear(); }); afterAll(async () => { await t.close(); testDb.close(); }); // ── getUserSettings ─────────────────────────────────────────────────────────── describe('getUserSettings', () => { it('SET-SVC-001 — returns empty object when user has no settings', async () => { const { user } = createUser(testDb); expect(await svc.getUserSettings(user.id)).toEqual({}); }); it('SET-SVC-002 — returns stored plain string values', async () => { const { user } = createUser(testDb); testDb.prepare("INSERT INTO settings (user_id, key, value) VALUES (?, 'theme', 'dark')").run(user.id); const s = await svc.getUserSettings(user.id); expect(s.theme).toBe('dark'); }); it('SET-SVC-003 — JSON-parses values that are valid JSON', async () => { const { user } = createUser(testDb); testDb.prepare("INSERT INTO settings (user_id, key, value) VALUES (?, 'count', '42')").run(user.id); testDb.prepare("INSERT INTO settings (user_id, key, value) VALUES (?, 'flag', 'true')").run(user.id); testDb.prepare("INSERT INTO settings (user_id, key, value) VALUES (?, 'obj', '{\"x\":1}')").run(user.id); const s = await svc.getUserSettings(user.id); expect(s.count).toBe(42); expect(s.flag).toBe(true); expect(s.obj).toEqual({ x: 1 }); }); it('SET-SVC-004 — falls back to raw string when value is not valid JSON', async () => { const { user } = createUser(testDb); testDb.prepare("INSERT INTO settings (user_id, key, value) VALUES (?, 'raw', 'not-json')").run(user.id); const s = await svc.getUserSettings(user.id); expect(s.raw).toBe('not-json'); }); it('SET-SVC-005 — webhook_url with a value is masked as ••••••••', async () => { const { user } = createUser(testDb); testDb.prepare("INSERT INTO settings (user_id, key, value) VALUES (?, 'webhook_url', 'https://secret.example.com')").run(user.id); const s = await svc.getUserSettings(user.id); expect(s.webhook_url).toBe('••••••••'); }); it('SET-SVC-006 — webhook_url with empty value returns empty string', async () => { const { user } = createUser(testDb); testDb.prepare("INSERT INTO settings (user_id, key, value) VALUES (?, 'webhook_url', '')").run(user.id); const s = await svc.getUserSettings(user.id); expect(s.webhook_url).toBe(''); }); it('SET-SVC-007 — only returns settings for the requesting user', async () => { const { user: a } = createUser(testDb); const { user: b } = createUser(testDb); testDb.prepare("INSERT INTO settings (user_id, key, value) VALUES (?, 'key_a', '\"a\"')").run(a.id); testDb.prepare("INSERT INTO settings (user_id, key, value) VALUES (?, 'key_b', '\"b\"')").run(b.id); const s = await svc.getUserSettings(a.id); expect(s).toHaveProperty('key_a'); expect(s).not.toHaveProperty('key_b'); }); // Admin "user defaults" fall-through (#1634) — a system-wide Mapbox token must // reach a user who left their own token blank. const setAdminDefault = (settingKey: string, value: string) => testDb.prepare( "INSERT INTO app_settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value", ).run(`default_user_setting_${settingKey}`, value); it('SET-SVC-020 — new user with no rows inherits the admin default token', async () => { const { user } = createUser(testDb); setAdminDefault('mapbox_access_token', 'pk.admin'); expect((await svc.getUserSettings(user.id)).mapbox_access_token).toBe('pk.admin'); }); it('SET-SVC-021 — an empty user token falls through to the admin default', async () => { const { user } = createUser(testDb); setAdminDefault('mapbox_access_token', 'pk.admin'); testDb.prepare("INSERT INTO settings (user_id, key, value) VALUES (?, 'mapbox_access_token', '')").run(user.id); expect((await svc.getUserSettings(user.id)).mapbox_access_token).toBe('pk.admin'); }); it('SET-SVC-022 — a non-empty user token overrides the admin default', async () => { const { user } = createUser(testDb); setAdminDefault('mapbox_access_token', 'pk.admin'); testDb.prepare("INSERT INTO settings (user_id, key, value) VALUES (?, 'mapbox_access_token', 'pk.user')").run(user.id); expect((await svc.getUserSettings(user.id)).mapbox_access_token).toBe('pk.user'); }); it('SET-SVC-023 — an empty value with no admin default stays empty (no regression)', async () => { const { user } = createUser(testDb); testDb.prepare("INSERT INTO settings (user_id, key, value) VALUES (?, 'mapbox_style', '')").run(user.id); expect((await svc.getUserSettings(user.id)).mapbox_style).toBe(''); }); it('SET-SVC-024 — a non-defaultable empty value is preserved even against a same-named default', async () => { const { user } = createUser(testDb); // 'theme' is not a defaultable key: an empty stored value must be returned as-is. testDb.prepare("INSERT INTO settings (user_id, key, value) VALUES (?, 'theme', '')").run(user.id); expect((await svc.getUserSettings(user.id)).theme).toBe(''); }); it('SET-SVC-025 — an inherited admin llm_api_key is masked, never returned in cleartext', async () => { const { user } = createUser(testDb); setAdminDefault('llm_api_key', 'sk-admin-secret'); // No user row → the value is inherited from the admin default; it is a secret // and must reach the client masked, not in cleartext. expect((await svc.getUserSettings(user.id)).llm_api_key).toBe('••••••••'); }); it('SET-SVC-026 — an empty user llm_api_key falls back to the admin key, still masked', async () => { const { user } = createUser(testDb); setAdminDefault('llm_api_key', 'sk-admin-secret'); testDb.prepare("INSERT INTO settings (user_id, key, value) VALUES (?, 'llm_api_key', '')").run(user.id); expect((await svc.getUserSettings(user.id)).llm_api_key).toBe('••••••••'); }); // carto_api_key (#2054): encrypted at rest like the other credentials, but // Leaflet builds the tile URL in the browser, so it must come back readable. it('SET-SVC-031 — carto_api_key is stored encrypted and read back in cleartext, never masked', async () => { const { user } = createUser(testDb); await svc.upsertSetting(user.id, 'carto_api_key', 'carto-user'); // Passthrough crypto mock: the stored row is whatever maybe_encrypt_api_key returned. const raw = testDb .prepare("SELECT value FROM settings WHERE user_id = ? AND key = 'carto_api_key'") .get(user.id) as { value: string }; expect(raw.value).toBe('carto-user'); expect((await svc.getUserSettings(user.id)).carto_api_key).toBe('carto-user'); // A digits-only key proves it takes the decrypt branch: a non-encrypted key // would come back JSON-parsed, as the number 12345. await svc.upsertSetting(user.id, 'carto_api_key', '12345'); expect((await svc.getUserSettings(user.id)).carto_api_key).toBe('12345'); }); it('SET-SVC-032 — an empty user carto_api_key falls back to the admin default', async () => { const { user } = createUser(testDb); setAdminDefault('carto_api_key', 'carto-admin'); testDb.prepare("INSERT INTO settings (user_id, key, value) VALUES (?, 'carto_api_key', '')").run(user.id); expect((await svc.getUserSettings(user.id)).carto_api_key).toBe('carto-admin'); }); it('SET-SVC-033 — a managed instance injects the operator key over both the user and the admin value', async () => { const { user } = createUser(testDb); setAdminDefault('carto_api_key', 'carto-admin'); testDb.prepare("INSERT INTO settings (user_id, key, value) VALUES (?, 'carto_api_key', 'carto-user')").run(user.id); vi.stubEnv('TREK_MANAGED', 'true'); vi.stubEnv('CARTO_API_KEY', 'carto-operator'); try { expect((await svc.getUserSettings(user.id)).carto_api_key).toBe('carto-operator'); } finally { vi.unstubAllEnvs(); } }); it('SET-SVC-034 — the injected CARTO key leaves map_provider alone', async () => { const { user } = createUser(testDb); vi.stubEnv('TREK_MANAGED', 'true'); vi.stubEnv('CARTO_API_KEY', 'carto-operator'); vi.stubEnv('MAPBOX_ACCESS_TOKEN', ''); try { // The Mapbox token flips a managed instance to GL because a token means a // renderer. A basemap key says nothing about which renderer the user wants. const s = await svc.getUserSettings(user.id); expect(s.carto_api_key).toBe('carto-operator'); expect(s.map_provider).toBeUndefined(); } finally { vi.unstubAllEnvs(); } }); }); // ── upsertSetting ───────────────────────────────────────────────────────────── describe('upsertSetting', () => { it('SET-SVC-008 — inserts a new setting', async () => { const { user } = createUser(testDb); await svc.upsertSetting(user.id, 'language', 'en'); const s = await svc.getUserSettings(user.id); expect(s.language).toBe('en'); }); it('SET-SVC-009 — updates an existing setting (ON CONFLICT)', async () => { const { user } = createUser(testDb); await svc.upsertSetting(user.id, 'language', 'en'); await svc.upsertSetting(user.id, 'language', 'fr'); const s = await svc.getUserSettings(user.id); expect(s.language).toBe('fr'); }); it('SET-SVC-010 — serializes object values as JSON', async () => { const { user } = createUser(testDb); await svc.upsertSetting(user.id, 'prefs', { dark: true, size: 14 }); const raw = testDb.prepare("SELECT value FROM settings WHERE user_id = ? AND key = 'prefs'").get(user.id) as any; expect(raw.value).toBe('{"dark":true,"size":14}'); }); it('SET-SVC-011 — serializes boolean values as strings', async () => { const { user } = createUser(testDb); await svc.upsertSetting(user.id, 'notifications', true); const raw = testDb.prepare("SELECT value FROM settings WHERE user_id = ? AND key = 'notifications'").get(user.id) as any; expect(raw.value).toBe('true'); }); it('SET-SVC-012 — webhook_url passes through maybe_encrypt_api_key', async () => { const { user } = createUser(testDb); await svc.upsertSetting(user.id, 'webhook_url', 'https://hook.example.com'); // With passthrough mock, value is stored as-is const raw = testDb.prepare("SELECT value FROM settings WHERE user_id = ? AND key = 'webhook_url'").get(user.id) as any; expect(raw.value).toBe('https://hook.example.com'); // But getUserSettings masks it const s = await svc.getUserSettings(user.id); expect(s.webhook_url).toBe('••••••••'); }); }); // ── bulkUpsertSettings ──────────────────────────────────────────────────────── describe('bulkUpsertSettings', () => { it('SET-SVC-013 — inserts multiple settings in one call', async () => { const { user } = createUser(testDb); await svc.bulkUpsertSettings(user.id, { a: 'alpha', b: 'beta', c: 'gamma' }); const s = await svc.getUserSettings(user.id); expect(s.a).toBe('alpha'); expect(s.b).toBe('beta'); expect(s.c).toBe('gamma'); }); it('SET-SVC-014 — returns the count of settings processed', async () => { const { user } = createUser(testDb); const count = await svc.bulkUpsertSettings(user.id, { x: 1, y: 2, z: 3 }); expect(count).toBe(3); }); it('SET-SVC-015 — updates existing keys (ON CONFLICT)', async () => { const { user } = createUser(testDb); await svc.upsertSetting(user.id, 'theme', 'light'); await svc.bulkUpsertSettings(user.id, { theme: 'dark', lang: 'en' }); const s = await svc.getUserSettings(user.id); expect(s.theme).toBe('dark'); expect(s.lang).toBe('en'); }); it('SET-SVC-016 — returns 0 for empty settings object', async () => { const { user } = createUser(testDb); const count = await svc.bulkUpsertSettings(user.id, {}); expect(count).toBe(0); }); it('SET-SVC-017 — all changes are committed atomically (transaction)', async () => { const { user } = createUser(testDb); await svc.bulkUpsertSettings(user.id, { p: '1', q: '2' }); const rows = testDb.prepare('SELECT key FROM settings WHERE user_id = ?').all(user.id) as any[]; const keys = rows.map((r: any) => r.key); expect(keys).toContain('p'); expect(keys).toContain('q'); }); it('SET-SVC-018 — settings from different users do not interfere', async () => { const { user: a } = createUser(testDb); const { user: b } = createUser(testDb); await svc.bulkUpsertSettings(a.id, { shared_key: 'from-a' }); await svc.bulkUpsertSettings(b.id, { shared_key: 'from-b' }); expect((await svc.getUserSettings(a.id) as any).shared_key).toBe('from-a'); expect((await svc.getUserSettings(b.id) as any).shared_key).toBe('from-b'); }); // Was a `vi.spyOn(testDb, 'prepare').mockImplementationOnce(...)` targeting // the legacy raw `INSERT INTO settings ...` statement. Now that // bulkUpsertSettings writes through SettingsRepository.upsertForUser // (em.upsert, via Kysely), the FIRST `.prepare()` call inside // `uow.transactional(...)` is Kysely's own internal machinery, not the // application statement — intercepting it corrupted the connection's // transaction/mutex state for the rest of the file (every test after this // one timed out). Per the inventory's own note for this test // ("narrow enough to become a mockRejectedValueOnce on the specific // repository method under test"), this now mocks the repository method // itself — no raw connection involved, so no risk of corrupting the shared // Kysely client's state for later tests. it('SET-SVC-019 — rolls back and re-throws when DB write fails mid-transaction', async () => { const { user } = createUser(testDb); const settingsRepo = await createTestSettingsRepo(testDb); const spy = vi.spyOn(settingsRepo, 'upsertForUser').mockRejectedValueOnce(new Error('forced DB error')); await expect(svc.bulkUpsertSettings(user.id, { k: 'v' })).rejects.toThrow('forced DB error'); expect(spy).toHaveBeenCalled(); spy.mockRestore(); }); }); // ── post-migration quirk fixes ──────────────────────────────────────────────── describe('legacy quirk fixes', () => { it('SET-SVC-027 — null serializes as the empty string, not the string "null"', async () => { const { user } = createUser(testDb); await svc.upsertSetting(user.id, 'llm_api_key', null); const raw = testDb.prepare("SELECT value FROM settings WHERE user_id = ? AND key = 'llm_api_key'").get(user.id) as { value: string }; expect(raw.value).toBe(''); // The legacy "null" storage leaked back out of getDecryptedUserSetting as // the literal string "null"; a cleared secret must read as null. expect(await svc.getDecryptedUserSetting(user.id, 'llm_api_key')).toBeNull(); }); it('SET-SVC-035 — a week_start default reaches users without their own, and theirs wins (#2029)', async () => { const { user: plain } = createUser(testDb); const { user: own } = createUser(testDb); await svc.setAdminUserDefaults({ week_start: 'sunday' }); await svc.upsertSetting(own.id, 'week_start', 'saturday'); expect((await svc.getUserSettings(plain.id)).week_start).toBe('sunday'); expect((await svc.getUserSettings(own.id)).week_start).toBe('saturday'); }); it('SET-SVC-036 — a week_start default outside monday/sunday/saturday is refused and not stored', async () => { await expect(svc.setAdminUserDefaults({ week_start: 'friday' })).rejects.toThrow(/Invalid value for week_start/); // Vacay's 0/1 numbers are not this setting's values either. await expect(svc.setAdminUserDefaults({ week_start: 0 })).rejects.toThrow(/Invalid value for week_start/); expect((await svc.getAdminUserDefaults()).week_start).toBeUndefined(); }); it('SET-SVC-028 — a nulled defaultable key falls through to the admin default', async () => { const { user } = createUser(testDb); await svc.setAdminUserDefaults({ mapbox_style: 'admin-style' }); await svc.upsertSetting(user.id, 'mapbox_style', null); expect((await svc.getUserSettings(user.id)).mapbox_style).toBe('admin-style'); }); it('SET-SVC-029 — bulk skips the masked sentinel so a stored secret survives', async () => { const { user } = createUser(testDb); await svc.upsertSetting(user.id, 'ntfy_token', 'tok-real'); const count = await svc.bulkUpsertSettings(user.id, { ntfy_topic: 'trek', ntfy_token: '••••••••' }); expect(count).toBe(1); // Passthrough crypto mock: the stored value must still be the real token. const raw = testDb.prepare("SELECT value FROM settings WHERE user_id = ? AND key = 'ntfy_token'").get(user.id) as { value: string }; expect(raw.value).toBe('tok-real'); expect(testDb.prepare("SELECT value FROM settings WHERE user_id = ? AND key = 'ntfy_topic'").get(user.id)).toEqual({ value: 'trek' }); }); it('SET-SVC-030 — bulk returns the count of keys actually written', async () => { const { user } = createUser(testDb); expect(await svc.bulkUpsertSettings(user.id, { a: '1', b: '••••••••' })).toBe(1); }); });