import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { HttpException, NotFoundException } from '@nestjs/common'; import type { Request } from 'express'; vi.mock('../../../src/services/auditLog', () => ({ writeAudit: vi.fn(), getClientIp: vi.fn(() => '1.2.3.4'), logInfo: vi.fn() })); vi.mock('../../../src/services/notificationService', () => ({ send: vi.fn().mockResolvedValue(undefined) })); import { AdminController } from '../../../src/nest/admin/admin.controller'; import type { AdminService } from '../../../src/nest/admin/admin.service'; import { writeAudit } from '../../../src/services/auditLog'; import { send as sendNotification } from '../../../src/services/notificationService'; import type { User } from '../../../src/types'; const user = { id: 1, role: 'admin', email: 'admin@example.test' } as User; const req = { headers: {} } as Request; function svc(o: Partial = {}): AdminService { return { invalidateMcpSessions: vi.fn(), ...o } as unknown as AdminService; } function thrown(fn: () => unknown): { status: number; body: unknown } { try { fn(); } catch (err) { if (err instanceof NotFoundException) return { status: 404, body: err.getResponse() }; expect(err).toBeInstanceOf(HttpException); const e = err as HttpException; return { status: e.getStatus(), body: e.getResponse() }; } throw new Error('expected throw'); } beforeEach(() => vi.clearAllMocks()); afterEach(() => { delete process.env.NODE_ENV; }); describe('AdminController users', () => { it('lists, creates (201 + audit), maps an error', () => { expect(new AdminController(svc({ listUsers: vi.fn().mockReturnValue([{ id: 1 }]) } as Partial)).listUsers()).toEqual({ users: [{ id: 1 }] }); expect(thrown(() => new AdminController(svc({ createUser: vi.fn().mockReturnValue({ error: 'Email taken', status: 409 }) } as Partial)).createUser(user, {}, req))).toEqual({ status: 409, body: { error: 'Email taken' } }); const c = new AdminController(svc({ createUser: vi.fn().mockReturnValue({ user: { id: 2 }, insertedId: 2, auditDetails: {} }) } as Partial)); expect(c.createUser(user, { email: 'a@b.c' }, req)).toEqual({ user: { id: 2 } }); expect(writeAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'admin.user_create' })); }); it('update + delete audit and map errors', () => { expect(new AdminController(svc({ updateUser: vi.fn().mockReturnValue({ user: { id: 2 }, previousEmail: 'a@b.c', changed: ['role'] }) } as Partial)).updateUser(user, '2', {}, req)).toEqual({ user: { id: 2 } }); expect(thrown(() => new AdminController(svc({ deleteUser: vi.fn().mockReturnValue({ error: 'Cannot delete self', status: 400 }) } as Partial)).deleteUser(user, '1', req))).toEqual({ status: 400, body: { error: 'Cannot delete self' } }); expect(new AdminController(svc({ deleteUser: vi.fn().mockReturnValue({ email: 'a@b.c' }) } as Partial)).deleteUser(user, '2', req)).toEqual({ success: true }); }); }); describe('AdminController permissions + oidc + misc', () => { it('permissions: 400 without an object, else saves + audits', () => { expect(thrown(() => new AdminController(svc()).savePermissions(user, {}, req))).toEqual({ status: 400, body: { error: 'permissions object required' } }); const c = new AdminController(svc({ savePermissions: vi.fn().mockReturnValue({ permissions: { x: 1 }, skipped: [] }) } as Partial)); expect(c.savePermissions(user, { permissions: { x: 1 } }, req)).toEqual({ success: true, permissions: { x: 1 } }); }); it('permissions: includes skipped when present', () => { const c = new AdminController(svc({ savePermissions: vi.fn().mockReturnValue({ permissions: {}, skipped: ['bad'] }) } as Partial)); expect(c.savePermissions(user, { permissions: {} }, req)).toEqual({ success: true, permissions: {}, skipped: ['bad'] }); }); it('oidc update maps error, else audits', () => { expect(thrown(() => new AdminController(svc({ updateOidcSettings: vi.fn().mockReturnValue({ error: 'bad issuer', status: 400 }) } as Partial)).updateOidc(user, {}, req))).toEqual({ status: 400, body: { error: 'bad issuer' } }); expect(new AdminController(svc({ updateOidcSettings: vi.fn().mockReturnValue({}) } as Partial)).updateOidc(user, { issuer: 'https://idp' }, req)).toEqual({ success: true }); }); it('save-demo-baseline maps error, else returns message', () => { expect(thrown(() => new AdminController(svc({ saveDemoBaseline: vi.fn().mockReturnValue({ error: 'not demo', status: 400 }) } as Partial)).saveDemoBaseline(user, req))).toEqual({ status: 400, body: { error: 'not demo' } }); expect(new AdminController(svc({ saveDemoBaseline: vi.fn().mockReturnValue({ message: 'saved' }) } as Partial)).saveDemoBaseline(user, req)).toEqual({ success: true, message: 'saved' }); }); }); describe('AdminController invites + feature toggles', () => { it('invites: create 201 + audit, delete maps error', () => { const c = new AdminController(svc({ createInvite: vi.fn().mockReturnValue({ invite: { id: 5 }, inviteId: 5, uses: 1, expiresInDays: 7 }) } as Partial)); expect(c.createInvite(user, {}, req)).toEqual({ invite: { id: 5 } }); expect(writeAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'admin.invite_create' })); expect(thrown(() => new AdminController(svc({ deleteInvite: vi.fn().mockReturnValue({ error: 'not found', status: 404 }) } as Partial)).deleteInvite(user, '5', req))).toEqual({ status: 404, body: { error: 'not found' } }); }); it('places-photos: 400 on a non-boolean, else updates + audits', () => { expect(thrown(() => new AdminController(svc()).updatePlacesPhotos(user, { enabled: 'yes' }, req))).toEqual({ status: 400, body: { error: 'enabled must be a boolean' } }); expect(new AdminController(svc({ updatePlacesPhotos: vi.fn().mockReturnValue({ enabled: true }) } as Partial)).updatePlacesPhotos(user, { enabled: true }, req)).toEqual({ enabled: true }); }); it('collab-features update invalidates MCP sessions + audits', () => { const invalidateMcpSessions = vi.fn(); const c = new AdminController(svc({ updateCollabFeatures: vi.fn().mockReturnValue({ chat: true }), invalidateMcpSessions } as Partial)); expect(c.updateCollabFeatures(user, { chat: true }, req)).toEqual({ chat: true }); expect(invalidateMcpSessions).toHaveBeenCalled(); }); }); describe('AdminController packing templates', () => { it('get 404, create 201, delete audits', () => { expect(thrown(() => new AdminController(svc({ getPackingTemplate: vi.fn().mockReturnValue({ error: 'not found', status: 404 }) } as Partial)).getPackingTemplate('9'))).toEqual({ status: 404, body: { error: 'not found' } }); expect(new AdminController(svc({ createPackingTemplate: vi.fn().mockReturnValue({ id: 3, name: 'Beach' }) } as Partial)).createPackingTemplate(user, { name: 'Beach' })).toEqual({ id: 3, name: 'Beach' }); expect(new AdminController(svc({ deletePackingTemplate: vi.fn().mockReturnValue({ name: 'Beach' }) } as Partial)).deletePackingTemplate(user, '3', req)).toEqual({ success: true }); expect(new AdminController(svc({ createTemplateItem: vi.fn().mockReturnValue({ id: 7 }) } as Partial)).createTemplateItem('3', '4', { name: 'Towel' })).toEqual({ id: 7 }); }); }); describe('AdminController addons + sessions + jwt + defaults', () => { it('addon update audits + invalidates MCP sessions', () => { const invalidateMcpSessions = vi.fn(); const c = new AdminController(svc({ updateAddon: vi.fn().mockReturnValue({ addon: { id: 'mcp', enabled: true }, auditDetails: {} }), invalidateMcpSessions } as Partial)); expect(c.updateAddon(user, 'mcp', { enabled: true }, req)).toEqual({ addon: { id: 'mcp', enabled: true } }); expect(invalidateMcpSessions).toHaveBeenCalled(); }); it('oauth-sessions revoke audits; rotate-jwt maps error', () => { expect(new AdminController(svc({ revokeOAuthSession: vi.fn().mockReturnValue({}) } as Partial)).revokeOAuthSession(user, '3', req)).toEqual({ success: true }); expect(thrown(() => new AdminController(svc({ rotateJwtSecret: vi.fn().mockReturnValue({ error: 'locked', status: 409 }) } as Partial)).rotateJwtSecret(user, req))).toEqual({ status: 409, body: { error: 'locked' } }); expect(new AdminController(svc({ rotateJwtSecret: vi.fn().mockReturnValue({}) } as Partial)).rotateJwtSecret(user, req)).toEqual({ success: true }); }); it('default-user-settings: 400 on a non-object, else sets + audits', () => { expect(thrown(() => new AdminController(svc()).setDefaultUserSettings(user, [], req))).toEqual({ status: 400, body: { error: 'Object body required' } }); const setAdminUserDefaults = vi.fn(); const c = new AdminController(svc({ setAdminUserDefaults, getAdminUserDefaults: vi.fn().mockReturnValue({ theme: 'dark' }) } as Partial)); expect(c.setDefaultUserSettings(user, { theme: 'dark' }, req)).toEqual({ theme: 'dark' }); expect(setAdminUserDefaults).toHaveBeenCalled(); }); }); describe('AdminController error envelope fallbacks', () => { it('ok() defaults to 400 when the error envelope omits a status', () => { expect(thrown(() => new AdminController(svc({ createUser: vi.fn().mockReturnValue({ error: 'boom' }) } as Partial)).createUser(user, {}, req))).toEqual({ status: 400, body: { error: 'boom' } }); }); it('updateOidc defaults to 400 when the service error omits a status', () => { expect(thrown(() => new AdminController(svc({ updateOidcSettings: vi.fn().mockReturnValue({ error: 'nope' }) } as Partial)).updateOidc(user, {}, req))).toEqual({ status: 400, body: { error: 'nope' } }); }); it('updateOidc audits issuer_set=false when no issuer is supplied', () => { expect(new AdminController(svc({ updateOidcSettings: vi.fn().mockReturnValue({}) } as Partial)).updateOidc(user, {}, req)).toEqual({ success: true }); expect(writeAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'admin.oidc_update', details: { issuer_set: false } })); }); }); describe('AdminController read-only getters', () => { it('return service values verbatim', () => { expect(new AdminController(svc({ resetUserPasskeys: vi.fn().mockReturnValue({ email: 'a@b.c', deleted: 2 }) } as Partial)).resetUserPasskeys(user, '4', req)).toEqual({ success: true, deleted: 2 }); expect(new AdminController(svc({ getStats: vi.fn().mockReturnValue({ users: 3 }) } as Partial)).stats()).toEqual({ users: 3 }); expect(new AdminController(svc({ getPermissions: vi.fn().mockReturnValue({ a: 1 }) } as Partial)).permissions()).toEqual({ a: 1 }); expect(new AdminController(svc({ getAuditLog: vi.fn().mockReturnValue({ entries: [] }) } as Partial)).auditLog({})).toEqual({ entries: [] }); expect(new AdminController(svc({ getOidcSettings: vi.fn().mockReturnValue({ issuer: 'x' }) } as Partial)).getOidc()).toEqual({ issuer: 'x' }); expect(new AdminController(svc({ checkVersion: vi.fn().mockResolvedValue({ current: '1' }) } as Partial)).versionCheck()).resolves.toEqual({ current: '1' }); expect(new AdminController(svc({ getPreferencesMatrix: vi.fn().mockReturnValue({ rows: [] }) } as Partial)).getNotificationPrefs(user)).toEqual({ rows: [] }); expect(new AdminController(svc({ listInvites: vi.fn().mockReturnValue([{ id: 1 }]) } as Partial)).listInvites()).toEqual({ invites: [{ id: 1 }] }); expect(new AdminController(svc({ getBagTracking: vi.fn().mockReturnValue({ enabled: false }) } as Partial)).getBagTracking()).toEqual({ enabled: false }); expect(new AdminController(svc({ getPlacesPhotos: vi.fn().mockReturnValue({ enabled: true }) } as Partial)).getPlacesPhotos()).toEqual({ enabled: true }); expect(new AdminController(svc({ getPlacesAutocomplete: vi.fn().mockReturnValue({ enabled: true }) } as Partial)).getPlacesAutocomplete()).toEqual({ enabled: true }); expect(new AdminController(svc({ getPlacesDetails: vi.fn().mockReturnValue({ enabled: true }) } as Partial)).getPlacesDetails()).toEqual({ enabled: true }); expect(new AdminController(svc({ getCollabFeatures: vi.fn().mockReturnValue({ chat: false }) } as Partial)).getCollabFeatures()).toEqual({ chat: false }); expect(new AdminController(svc({ listPackingTemplates: vi.fn().mockReturnValue([{ id: 1 }]) } as Partial)).listPackingTemplates()).toEqual({ templates: [{ id: 1 }] }); expect(new AdminController(svc({ listAddons: vi.fn().mockReturnValue([{ id: 'mcp' }]) } as Partial)).listAddons()).toEqual({ addons: [{ id: 'mcp' }] }); expect(new AdminController(svc({ listMcpTokens: vi.fn().mockReturnValue([{ id: 1 }]) } as Partial)).listMcpTokens()).toEqual({ tokens: [{ id: 1 }] }); expect(new AdminController(svc({ listOAuthSessions: vi.fn().mockReturnValue([{ id: 1 }]) } as Partial)).listOAuthSessions()).toEqual({ sessions: [{ id: 1 }] }); expect(new AdminController(svc({ getAdminUserDefaults: vi.fn().mockReturnValue({ theme: 'dark' }) } as Partial)).getDefaultUserSettings()).toEqual({ theme: 'dark' }); }); it('setNotificationPrefs persists then returns the refreshed matrix', () => { const setAdminPreferences = vi.fn(); const c = new AdminController(svc({ setAdminPreferences, getPreferencesMatrix: vi.fn().mockReturnValue({ rows: [1] }) } as Partial)); expect(c.setNotificationPrefs(user, { x: 1 })).toEqual({ rows: [1] }); expect(setAdminPreferences).toHaveBeenCalledWith(user.id, { x: 1 }); }); it('githubReleases falls back to default paging when no query is given', async () => { const getGithubReleases = vi.fn().mockResolvedValue([{ tag: 'v1' }]); const c = new AdminController(svc({ getGithubReleases } as Partial)); await expect(c.githubReleases()).resolves.toEqual([{ tag: 'v1' }]); expect(getGithubReleases).toHaveBeenCalledWith('10', '1'); await c.githubReleases('5', '2'); expect(getGithubReleases).toHaveBeenLastCalledWith('5', '2'); }); }); describe('AdminController feature toggles + audit', () => { it('bag-tracking updates and audits', () => { const c = new AdminController(svc({ updateBagTracking: vi.fn().mockReturnValue({ enabled: true }) } as Partial)); expect(c.updateBagTracking(user, { enabled: true }, req)).toEqual({ enabled: true }); expect(writeAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'admin.bag_tracking' })); }); it('places-autocomplete: 400 on a non-boolean, else updates + audits', () => { expect(thrown(() => new AdminController(svc()).updatePlacesAutocomplete(user, { enabled: 'yes' }, req))).toEqual({ status: 400, body: { error: 'enabled must be a boolean' } }); expect(new AdminController(svc({ updatePlacesAutocomplete: vi.fn().mockReturnValue({ enabled: false }) } as Partial)).updatePlacesAutocomplete(user, { enabled: false }, req)).toEqual({ enabled: false }); }); it('places-details: 400 on a non-boolean, else updates + audits', () => { expect(thrown(() => new AdminController(svc()).updatePlacesDetails(user, { enabled: 1 }, req))).toEqual({ status: 400, body: { error: 'enabled must be a boolean' } }); expect(new AdminController(svc({ updatePlacesDetails: vi.fn().mockReturnValue({ enabled: true }) } as Partial)).updatePlacesDetails(user, { enabled: true }, req)).toEqual({ enabled: true }); }); }); describe('AdminController packing template sub-routes', () => { it('update/delete templates, categories and items map errors + return success', () => { expect(new AdminController(svc({ updatePackingTemplate: vi.fn().mockReturnValue({ id: 3 }) } as Partial)).updatePackingTemplate('3', {})).toEqual({ id: 3 }); expect(new AdminController(svc({ createTemplateCategory: vi.fn().mockReturnValue({ id: 4 }) } as Partial)).createTemplateCategory('3', { name: 'Tops' })).toEqual({ id: 4 }); expect(new AdminController(svc({ updateTemplateCategory: vi.fn().mockReturnValue({ id: 4 }) } as Partial)).updateTemplateCategory('3', '4', {})).toEqual({ id: 4 }); expect(new AdminController(svc({ deleteTemplateCategory: vi.fn().mockReturnValue({}) } as Partial)).deleteTemplateCategory('3', '4')).toEqual({ success: true }); expect(new AdminController(svc({ updateTemplateItem: vi.fn().mockReturnValue({ id: 7 }) } as Partial)).updateTemplateItem('7', {})).toEqual({ id: 7 }); expect(new AdminController(svc({ deleteTemplateItem: vi.fn().mockReturnValue({}) } as Partial)).deleteTemplateItem('7')).toEqual({ success: true }); expect(thrown(() => new AdminController(svc({ deleteTemplateItem: vi.fn().mockReturnValue({ error: 'gone', status: 404 }) } as Partial)).deleteTemplateItem('9'))).toEqual({ status: 404, body: { error: 'gone' } }); }); }); describe('AdminController tokens + sessions', () => { it('mcp token + oauth session deletes return success and map errors', () => { expect(new AdminController(svc({ deleteMcpToken: vi.fn().mockReturnValue({}) } as Partial)).deleteMcpToken('2')).toEqual({ success: true }); expect(thrown(() => new AdminController(svc({ deleteMcpToken: vi.fn().mockReturnValue({ error: 'no token', status: 404 }) } as Partial)).deleteMcpToken('9'))).toEqual({ status: 404, body: { error: 'no token' } }); expect(thrown(() => new AdminController(svc({ revokeOAuthSession: vi.fn().mockReturnValue({ error: 'no session', status: 404 }) } as Partial)).revokeOAuthSession(user, '9', req))).toEqual({ status: 404, body: { error: 'no session' } }); }); }); describe('AdminController default-user-settings error path', () => { it('400 with an Error message when setAdminUserDefaults throws an Error', () => { const c = new AdminController(svc({ setAdminUserDefaults: vi.fn(() => { throw new Error('bad default'); }) } as Partial)); expect(thrown(() => c.setDefaultUserSettings(user, { theme: 'x' }, req))).toEqual({ status: 400, body: { error: 'bad default' } }); }); it('400 stringifies a non-Error throw', () => { const c = new AdminController(svc({ setAdminUserDefaults: vi.fn(() => { throw 'plain string'; }) } as Partial)); expect(thrown(() => c.setDefaultUserSettings(user, { theme: 'x' }, req))).toEqual({ status: 400, body: { error: 'plain string' } }); }); it('400 when the body is null', () => { expect(thrown(() => new AdminController(svc()).setDefaultUserSettings(user, null, req))).toEqual({ status: 400, body: { error: 'Object body required' } }); }); }); describe('AdminController dev test-notification', () => { it('404 outside development', async () => { delete process.env.NODE_ENV; await expect(new AdminController(svc()).devTestNotification(user, {})).rejects.toBeInstanceOf(NotFoundException); }); it('sends in development', async () => { process.env.NODE_ENV = 'development'; const res = await new AdminController(svc()).devTestNotification(user, { event: 'trip_reminder' }); expect(res).toEqual({ success: true }); }); it('applies notification defaults when the body is empty', async () => { process.env.NODE_ENV = 'development'; const res = await new AdminController(svc()).devTestNotification(user, {}); expect(res).toEqual({ success: true }); expect(sendNotification).toHaveBeenCalledWith(expect.objectContaining({ event: 'trip_reminder', scope: 'user', targetId: user.id })); }); it('maps an Error from the notification service to 400', async () => { process.env.NODE_ENV = 'development'; (sendNotification as unknown as ReturnType).mockRejectedValueOnce(new Error('send failed')); await expect(new AdminController(svc()).devTestNotification(user, { event: 'trip_reminder' })).rejects.toMatchObject({ response: { error: 'send failed' } }); }); it('stringifies a non-Error notification failure to 400', async () => { process.env.NODE_ENV = 'development'; (sendNotification as unknown as ReturnType).mockRejectedValueOnce('weird'); await expect(new AdminController(svc()).devTestNotification(user, { event: 'trip_reminder' })).rejects.toMatchObject({ response: { error: 'weird' } }); }); });