import {describe, expect, it, vi, beforeEach} from 'vitest'; const connection = { query: vi.fn(), execute: vi.fn(), beginTransaction: vi.fn(), commit: vi.fn(), rollback: vi.fn(), end: vi.fn() }; vi.mock('../../src/models/calendar/Calendar.db.js', () => ({ NachklangCalendarDB: {getConnection: vi.fn(async () => connection)} })); vi.mock('../../src/models/admin/users/users.admin.service.js', () => ({ findDisplayNames: vi.fn() })); import {NachklangCalendarDB} from '../../src/models/calendar/Calendar.db.js'; import * as AdminUsersService from '../../src/models/admin/users/users.admin.service.js'; import * as EventService from '../../src/models/calendar/events/events.service.js'; /** * Step 3 of docs/calendar-auth-migration.md. The property under test is that * an event's creator resolves from whichever of its three possible sources is * strongest - the live admin name, then the snapshot from migration 002, then * the legacy join - and that a failure to reach the admin database costs a name * rather than the whole response: the public calendar is read anonymously by * the website and has never depended on the admin database being up. */ // One row of the shape the shared SELECT produces. const row = (over: Record = {}) => ({ event_id: 1, calendar_id: 1, uuid: 'uuid-1', name: 'Konzert', description: '', start_datetime: new Date('2026-04-18T19:00:00Z'), end_datetime: new Date('2026-04-18T21:00:00Z'), created_date: new Date('2026-01-01T00:00:00Z'), version_created_at: new Date('2026-01-02T00:00:00Z'), location: '', created_by_id: 7, created_by_user_id: null, created_by_name: null, legacy_created_by_name: 'Legacy Person', version_created_by_id: 7, version_created_by_user_id: null, version_created_by_name: null, legacy_last_modified_by_name: 'Legacy Person', url: '', whole_day: 0, repeat_frequency: '', status: 'PUBLIC', ...over }); /** getAllEvents runs the calendars lookup first, then the events query. */ const givenEvents = (...rows: unknown[]) => { connection.query.mockReset(); connection.query .mockResolvedValueOnce([{calendar_id: 1, includes_calendars: '[]'}]) .mockResolvedValueOnce(rows); }; beforeEach(() => { vi.clearAllMocks(); connection.end.mockResolvedValue(undefined); (NachklangCalendarDB.getConnection as any).mockResolvedValue(connection); }); describe('creator names', () => { it('uses the legacy join when the row has no admin id', async () => { givenEvents(row()); const events = await EventService.getAllEvents(1); expect(events[0].createdBy).toBe('Legacy Person'); expect(events[0].createdById).toBe(7); expect(events[0].createdByUserId).toBeNull(); // Nothing to resolve, so the admin database is not touched at all. expect(AdminUsersService.findDisplayNames).not.toHaveBeenCalled(); }); it('prefers the admin name when the row carries an admin id', async () => { givenEvents(row({ created_by_user_id: 'admin-1', version_created_by_user_id: 'admin-2' })); (AdminUsersService.findDisplayNames as any).mockResolvedValue( new Map([['admin-1', 'Neue Person'], ['admin-2', 'Andere Person']]) ); const events = await EventService.getAllEvents(1); expect(events[0].createdBy).toBe('Neue Person'); expect(events[0].lastModifiedBy).toBe('Andere Person'); // The legacy id is still reported during the transition. expect(events[0].createdById).toBe(7); expect(events[0].createdByUserId).toBe('admin-1'); }); it('prefers the snapshot over the legacy join', async () => { givenEvents(row({ created_by_name: 'Archived Person', version_created_by_name: 'Archived Person' })); const events = await EventService.getAllEvents(1); expect(events[0].createdBy).toBe('Archived Person'); expect(events[0].lastModifiedBy).toBe('Archived Person'); }); it('prefers the live admin name over the snapshot', async () => { // A renamed account has to win over an archive that was correct when it // was taken - otherwise renaming someone would leave stale names behind. givenEvents(row({created_by_user_id: 'admin-1', created_by_name: 'Archived Person'})); (AdminUsersService.findDisplayNames as any).mockResolvedValue(new Map([['admin-1', 'Neue Person']])); const events = await EventService.getAllEvents(1); expect(events[0].createdBy).toBe('Neue Person'); }); it('keeps the snapshot when step 5 has removed the legacy join', async () => { // What a post-step-5 row looks like: no legacy id, no join, snapshot only. givenEvents(row({ created_by_id: null, legacy_created_by_name: undefined, legacy_last_modified_by_name: undefined, created_by_name: 'Archived Person', version_created_by_name: 'Archived Person' })); const events = await EventService.getAllEvents(1); expect(events[0].createdBy).toBe('Archived Person'); expect(events[0].lastModifiedBy).toBe('Archived Person'); }); it('falls back to the legacy name when the admin account is gone', async () => { givenEvents(row({created_by_user_id: 'deleted'})); (AdminUsersService.findDisplayNames as any).mockResolvedValue(new Map()); const events = await EventService.getAllEvents(1); expect(events[0].createdBy).toBe('Legacy Person'); }); it('resolves a mixed result set in a single lookup', async () => { givenEvents( row({event_id: 1}), row({event_id: 2, created_by_user_id: 'admin-1', version_created_by_user_id: 'admin-1'}), row({event_id: 3, created_by_user_id: 'admin-1', version_created_by_user_id: 'admin-1'}) ); (AdminUsersService.findDisplayNames as any).mockResolvedValue(new Map([['admin-1', 'Neue Person']])); const events = await EventService.getAllEvents(1); expect(events.map(e => e.createdBy)).toEqual(['Legacy Person', 'Neue Person', 'Neue Person']); expect(AdminUsersService.findDisplayNames).toHaveBeenCalledTimes(1); }); it('still returns the events when the admin database is unreachable', async () => { givenEvents(row({created_by_user_id: 'admin-1'})); (AdminUsersService.findDisplayNames as any).mockRejectedValue(new Error('ECONNREFUSED')); const events = await EventService.getAllEvents(1); expect(events).toHaveLength(1); expect(events[0].name).toBe('Konzert'); // Degrades to the legacy name rather than failing the request. expect(events[0].createdBy).toBe('Legacy Person'); }); }); describe('status', () => { it('is omitted from the public listing and present in the admin one', async () => { givenEvents(row()); const publicEvents = await EventService.getAllEvents(1); expect(publicEvents[0].status).toBeUndefined(); connection.query.mockReset(); connection.query.mockResolvedValueOnce([row()]); const adminEvents = await EventService.getAllEventsAdmin(1); expect(adminEvents[0].status).toBe('PUBLIC'); }); });