Read calendar event creators from the admin module, and archive the old ones (#14)
Jenkins Production Deployment

Reviewed-on: #14
Co-authored-by: Patrick Müller <mail@pmueller.me>
Co-committed-by: Patrick Müller <mail@pmueller.me>
This commit was merged in pull request #14.
This commit is contained in:
2026-09-06 21:12:16 +00:00
committed by Patrick Müller
parent 3c892d02ed
commit 13a0c07d1b
18 changed files with 1346 additions and 483 deletions
+191
View File
@@ -0,0 +1,191 @@
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<string, unknown> = {}) => ({
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');
});
});