import { ContractDocumentHistoryService } from './contract-document-history.service'; /** * `iam.users.name` is a localized jsonb object, not a string. Reading it * naively puts "[object Object]" in the audit trail — or, worse, throws and * leaves every revision anonymous. These specs pin the resolution rules. */ describe('ContractDocumentHistoryService actor names', () => { const build = (rows: unknown[]) => { const saved: Array> = []; const service = Object.create( ContractDocumentHistoryService.prototype, ) as ContractDocumentHistoryService; Object.assign(service, { logger: { warn: jest.fn(), error: jest.fn() }, dataSource: { query: jest.fn().mockResolvedValue(rows) }, revisionRepo: { create: (row: Record) => row, save: jest.fn((row: Record) => { saved.push(row); return Promise.resolve(row); }), find: jest.fn().mockResolvedValue([]), }, }); return { service, saved }; }; const change = { kind: 'FIELD_CHANGED' as const, field: 'routes', label: 'Routes', from: 'A → B', to: 'A → C', }; it('prefers the English label from the localized name object', async () => { const { service, saved } = build([ { id: 'u-1', name: { am: 'ሱፐር አድሚን', en: 'Super Admin' }, username: 'superadmin' }, ]); await service.recordChanges({ contractId: 'c-1', changes: [change], actorId: 'u-1' }); expect(saved[0].actorName).toBe('Super Admin'); }); it('falls back to another locale, then username, then email', async () => { const onlyAmharic = build([{ id: 'u-1', name: { am: 'ሱፐር' }, username: 'x' }]); await onlyAmharic.service.recordChanges({ contractId: 'c-1', changes: [change], actorId: 'u-1', }); expect(onlyAmharic.saved[0].actorName).toBe('ሱፐር'); const noName = build([{ id: 'u-1', name: null, username: 'operator', email: 'o@edr' }]); await noName.service.recordChanges({ contractId: 'c-1', changes: [change], actorId: 'u-1', }); expect(noName.saved[0].actorName).toBe('operator'); const emailOnly = build([{ id: 'u-1', name: {}, username: null, email: 'o@edr.local' }]); await emailOnly.service.recordChanges({ contractId: 'c-1', changes: [change], actorId: 'u-1', }); expect(emailOnly.saved[0].actorName).toBe('o@edr.local'); }); it('never writes "[object Object]" as the actor name', async () => { const { service, saved } = build([{ id: 'u-1', name: { en: 'Real Name' } }]); await service.recordChanges({ contractId: 'c-1', changes: [change], actorId: 'u-1' }); expect(String(saved[0].actorName)).not.toContain('object Object'); }); it('records nothing when the change set is empty', async () => { const { service, saved } = build([]); await service.recordChanges({ contractId: 'c-1', changes: [], actorId: 'u-1' }); expect(saved).toHaveLength(0); }); it('still records the revision when the user lookup fails', async () => { const { service, saved } = build([]); Object.assign(service, { dataSource: { query: jest.fn().mockRejectedValue(new Error('iam down')) }, }); await service.recordChanges({ contractId: 'c-1', changes: [change], actorId: 'u-1' }); expect(saved).toHaveLength(1); expect(saved[0].actorName).toBeNull(); }); it('resolves names for legacy rows that predate the actor_name column', async () => { const { service } = build([{ id: 'u-1', name: { en: 'Abenezer Haile' } }]); Object.assign(service, { revisionRepo: { find: jest .fn() .mockResolvedValue([{ id: 'r-1', actorId: 'u-1', actorName: null }]), }, }); const [revision] = await service.list('c-1'); expect(revision.actorName).toBe('Abenezer Haile'); }); });