feat: sync calendar entries to a Microsoft 365 group calendar
Adds a one-way push (ApiCore -> M365) of Aufgaben/Durchsichten/ Behandlungen/Fütterungen/Ernten/Königinnen-Zuordnungen into the calendar of a Microsoft 365 group, via the Graph API. - CalendarSyncModule (app-only client-credentials Graph auth, separate from the interactive SSO app registration; new env vars M365_CALENDAR_TENANT_ID/_CLIENT_ID/_CLIENT_SECRET). - Outbox + minute-interval cron worker (CalendarSyncOutboxEntry) instead of a synchronous push, since Graph calls are too slow/flaky to block a domain create request on; failed syncs retry up to 5 times. - Per-source-type toggle plus a group-ID field, configurable in Verwaltung > Automatisierung > Kalender-Sync (Verwaltung > Backup, an env var still wins on those fields where one applies to related config). - Hooked into Tasks/Inspections/Treatments/Feedings/Harvests/Queens at their existing create (and Task update/complete) points; entries render as all-day Graph events to avoid timezone ambiguity. - Documents the design decision in ARCHITECTURE.md (Annahme 9), since this is a net-new capability the lastenheft doesn't scope. Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
1 parent
28ae0a2723
commit
e7b157ec35
35 files changed
+1760
-7
No files matched your search
@@ -0,0 +1,372 @@
|
||||
import { CalendarSyncService } from './calendar-sync.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { CalendarSyncSettingsService } from './calendar-sync-settings.service';
|
||||
import { GraphClientService } from './graph-client.service';
|
||||
|
||||
describe('CalendarSyncService', () => {
|
||||
let service: CalendarSyncService;
|
||||
let prisma: {
|
||||
calendarSyncOutboxEntry: {
|
||||
upsert: jest.Mock;
|
||||
findUnique: jest.Mock;
|
||||
update: jest.Mock;
|
||||
findMany: jest.Mock;
|
||||
count: jest.Mock;
|
||||
};
|
||||
task: { findUnique: jest.Mock };
|
||||
inspection: { findUnique: jest.Mock };
|
||||
treatment: { findUnique: jest.Mock };
|
||||
feeding: { findUnique: jest.Mock };
|
||||
harvest: { findUnique: jest.Mock };
|
||||
queenColonyHistory: { findUnique: jest.Mock };
|
||||
};
|
||||
let settingsService: { get: jest.Mock };
|
||||
let graphClient: {
|
||||
isConfigured: jest.Mock;
|
||||
createEvent: jest.Mock;
|
||||
updateEvent: jest.Mock;
|
||||
deleteEvent: jest.Mock;
|
||||
};
|
||||
|
||||
const enabledSettings = {
|
||||
id: 'singleton',
|
||||
enabled: true,
|
||||
groupId: 'group-1',
|
||||
syncTask: true,
|
||||
syncInspection: true,
|
||||
syncTreatment: true,
|
||||
syncFeeding: true,
|
||||
syncHarvest: true,
|
||||
syncQueenAssignment: true,
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
prisma = {
|
||||
calendarSyncOutboxEntry: {
|
||||
upsert: jest.fn(),
|
||||
findUnique: jest.fn(),
|
||||
update: jest.fn(),
|
||||
findMany: jest.fn(),
|
||||
count: jest.fn().mockResolvedValue(0),
|
||||
},
|
||||
task: { findUnique: jest.fn() },
|
||||
inspection: { findUnique: jest.fn() },
|
||||
treatment: { findUnique: jest.fn() },
|
||||
feeding: { findUnique: jest.fn() },
|
||||
harvest: { findUnique: jest.fn() },
|
||||
queenColonyHistory: { findUnique: jest.fn() },
|
||||
};
|
||||
settingsService = { get: jest.fn().mockResolvedValue(enabledSettings) };
|
||||
graphClient = {
|
||||
isConfigured: jest.fn().mockReturnValue(true),
|
||||
createEvent: jest.fn(),
|
||||
updateEvent: jest.fn(),
|
||||
deleteEvent: jest.fn(),
|
||||
};
|
||||
service = new CalendarSyncService(
|
||||
prisma as unknown as PrismaService,
|
||||
settingsService as unknown as CalendarSyncSettingsService,
|
||||
graphClient as unknown as GraphClientService,
|
||||
);
|
||||
});
|
||||
|
||||
describe('enqueue', () => {
|
||||
it('does nothing when sync is disabled overall', async () => {
|
||||
settingsService.get.mockResolvedValue({
|
||||
...enabledSettings,
|
||||
enabled: false,
|
||||
});
|
||||
|
||||
await service.enqueue('TASK', 'task-1');
|
||||
|
||||
expect(prisma.calendarSyncOutboxEntry.upsert).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does nothing when the source type toggle is off', async () => {
|
||||
settingsService.get.mockResolvedValue({
|
||||
...enabledSettings,
|
||||
syncTask: false,
|
||||
});
|
||||
|
||||
await service.enqueue('TASK', 'task-1');
|
||||
|
||||
expect(prisma.calendarSyncOutboxEntry.upsert).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('upserts a PENDING row, resetting attempts and lastError', async () => {
|
||||
await service.enqueue('TASK', 'task-1');
|
||||
|
||||
expect(prisma.calendarSyncOutboxEntry.upsert).toHaveBeenCalledWith({
|
||||
where: {
|
||||
sourceType_sourceId: { sourceType: 'TASK', sourceId: 'task-1' },
|
||||
},
|
||||
create: { sourceType: 'TASK', sourceId: 'task-1' },
|
||||
update: { status: 'PENDING', attempts: 0, lastError: null },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('cancel', () => {
|
||||
it('does nothing when no outbox row exists', async () => {
|
||||
prisma.calendarSyncOutboxEntry.findUnique.mockResolvedValue(null);
|
||||
|
||||
await service.cancel('TASK', 'task-1');
|
||||
|
||||
expect(prisma.calendarSyncOutboxEntry.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('deletes the Graph event and clears the row when a graphEventId exists', async () => {
|
||||
prisma.calendarSyncOutboxEntry.findUnique.mockResolvedValue({
|
||||
id: 'entry-1',
|
||||
graphEventId: 'graph-event-1',
|
||||
});
|
||||
|
||||
await service.cancel('TASK', 'task-1');
|
||||
|
||||
expect(graphClient.deleteEvent).toHaveBeenCalledWith(
|
||||
'group-1',
|
||||
'graph-event-1',
|
||||
);
|
||||
expect(prisma.calendarSyncOutboxEntry.update).toHaveBeenCalledWith({
|
||||
where: { id: 'entry-1' },
|
||||
data: { status: 'SKIPPED', graphEventId: null },
|
||||
});
|
||||
});
|
||||
|
||||
it('still clears the row when the Graph delete fails', async () => {
|
||||
prisma.calendarSyncOutboxEntry.findUnique.mockResolvedValue({
|
||||
id: 'entry-1',
|
||||
graphEventId: 'graph-event-1',
|
||||
});
|
||||
graphClient.deleteEvent.mockRejectedValue(new Error('network down'));
|
||||
|
||||
await service.cancel('TASK', 'task-1');
|
||||
|
||||
expect(prisma.calendarSyncOutboxEntry.update).toHaveBeenCalledWith({
|
||||
where: { id: 'entry-1' },
|
||||
data: { status: 'SKIPPED', graphEventId: null },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('processOutbox', () => {
|
||||
it('does nothing when sync is disabled, unconfigured, or no groupId', async () => {
|
||||
settingsService.get.mockResolvedValue({
|
||||
...enabledSettings,
|
||||
enabled: false,
|
||||
});
|
||||
await service.processOutbox();
|
||||
expect(prisma.calendarSyncOutboxEntry.findMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('creates a Graph event for a new TASK entry and marks it SYNCED', async () => {
|
||||
prisma.calendarSyncOutboxEntry.findMany.mockResolvedValue([
|
||||
{
|
||||
id: 'entry-1',
|
||||
sourceType: 'TASK',
|
||||
sourceId: 'task-1',
|
||||
graphEventId: null,
|
||||
attempts: 0,
|
||||
},
|
||||
]);
|
||||
prisma.task.findUnique.mockResolvedValue({
|
||||
id: 'task-1',
|
||||
title: 'Beute prüfen',
|
||||
dueDate: new Date('2026-03-07'),
|
||||
});
|
||||
graphClient.createEvent.mockResolvedValue('graph-event-1');
|
||||
|
||||
await service.processOutbox();
|
||||
|
||||
expect(graphClient.createEvent).toHaveBeenCalledWith('group-1', {
|
||||
title: 'Beute prüfen',
|
||||
date: new Date('2026-03-07'),
|
||||
});
|
||||
expect(prisma.calendarSyncOutboxEntry.update).toHaveBeenCalledWith({
|
||||
where: { id: 'entry-1' },
|
||||
data: {
|
||||
status: 'SYNCED',
|
||||
graphEventId: 'graph-event-1',
|
||||
lastError: null,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('marks the entry SKIPPED when the Task no longer has a dueDate', async () => {
|
||||
prisma.calendarSyncOutboxEntry.findMany.mockResolvedValue([
|
||||
{
|
||||
id: 'entry-1',
|
||||
sourceType: 'TASK',
|
||||
sourceId: 'task-1',
|
||||
graphEventId: null,
|
||||
attempts: 0,
|
||||
},
|
||||
]);
|
||||
prisma.task.findUnique.mockResolvedValue({
|
||||
id: 'task-1',
|
||||
title: 'Beute prüfen',
|
||||
dueDate: null,
|
||||
});
|
||||
|
||||
await service.processOutbox();
|
||||
|
||||
expect(graphClient.createEvent).not.toHaveBeenCalled();
|
||||
expect(prisma.calendarSyncOutboxEntry.update).toHaveBeenCalledWith({
|
||||
where: { id: 'entry-1' },
|
||||
data: { status: 'SKIPPED' },
|
||||
});
|
||||
});
|
||||
|
||||
it('updates an existing Graph event when graphEventId is already set', async () => {
|
||||
prisma.calendarSyncOutboxEntry.findMany.mockResolvedValue([
|
||||
{
|
||||
id: 'entry-1',
|
||||
sourceType: 'TASK',
|
||||
sourceId: 'task-1',
|
||||
graphEventId: 'graph-event-1',
|
||||
attempts: 0,
|
||||
},
|
||||
]);
|
||||
prisma.task.findUnique.mockResolvedValue({
|
||||
id: 'task-1',
|
||||
title: 'Beute prüfen (geändert)',
|
||||
dueDate: new Date('2026-03-10'),
|
||||
});
|
||||
graphClient.updateEvent.mockResolvedValue(undefined);
|
||||
|
||||
await service.processOutbox();
|
||||
|
||||
expect(graphClient.updateEvent).toHaveBeenCalledWith(
|
||||
'group-1',
|
||||
'graph-event-1',
|
||||
{ title: 'Beute prüfen (geändert)', date: new Date('2026-03-10') },
|
||||
);
|
||||
expect(graphClient.createEvent).not.toHaveBeenCalled();
|
||||
expect(prisma.calendarSyncOutboxEntry.update).toHaveBeenCalledWith({
|
||||
where: { id: 'entry-1' },
|
||||
data: {
|
||||
status: 'SYNCED',
|
||||
graphEventId: 'graph-event-1',
|
||||
lastError: null,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to creating a fresh event when updating a stale graphEventId 404s', async () => {
|
||||
prisma.calendarSyncOutboxEntry.findMany.mockResolvedValue([
|
||||
{
|
||||
id: 'entry-1',
|
||||
sourceType: 'TASK',
|
||||
sourceId: 'task-1',
|
||||
graphEventId: 'deleted-on-m365',
|
||||
attempts: 0,
|
||||
},
|
||||
]);
|
||||
prisma.task.findUnique.mockResolvedValue({
|
||||
id: 'task-1',
|
||||
title: 'Beute prüfen',
|
||||
dueDate: new Date('2026-03-07'),
|
||||
});
|
||||
graphClient.updateEvent.mockRejectedValue(new Error('404 Not Found'));
|
||||
graphClient.createEvent.mockResolvedValue('graph-event-new');
|
||||
|
||||
await service.processOutbox();
|
||||
|
||||
expect(graphClient.createEvent).toHaveBeenCalled();
|
||||
expect(prisma.calendarSyncOutboxEntry.update).toHaveBeenCalledWith({
|
||||
where: { id: 'entry-1' },
|
||||
data: {
|
||||
status: 'SYNCED',
|
||||
graphEventId: 'graph-event-new',
|
||||
lastError: null,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('retries (stays PENDING) on failure below the max attempt count', async () => {
|
||||
prisma.calendarSyncOutboxEntry.findMany.mockResolvedValue([
|
||||
{
|
||||
id: 'entry-1',
|
||||
sourceType: 'TASK',
|
||||
sourceId: 'task-1',
|
||||
graphEventId: null,
|
||||
attempts: 1,
|
||||
},
|
||||
]);
|
||||
prisma.task.findUnique.mockRejectedValue(new Error('db error'));
|
||||
|
||||
await service.processOutbox();
|
||||
|
||||
expect(prisma.calendarSyncOutboxEntry.update).toHaveBeenCalledWith({
|
||||
where: { id: 'entry-1' },
|
||||
data: { status: 'PENDING', attempts: 2, lastError: 'db error' },
|
||||
});
|
||||
});
|
||||
|
||||
it('marks FAILED once the max attempt count is reached', async () => {
|
||||
prisma.calendarSyncOutboxEntry.findMany.mockResolvedValue([
|
||||
{
|
||||
id: 'entry-1',
|
||||
sourceType: 'TASK',
|
||||
sourceId: 'task-1',
|
||||
graphEventId: null,
|
||||
attempts: 4,
|
||||
},
|
||||
]);
|
||||
prisma.task.findUnique.mockRejectedValue(new Error('db error'));
|
||||
|
||||
await service.processOutbox();
|
||||
|
||||
expect(prisma.calendarSyncOutboxEntry.update).toHaveBeenCalledWith({
|
||||
where: { id: 'entry-1' },
|
||||
data: { status: 'FAILED', attempts: 5, lastError: 'db error' },
|
||||
});
|
||||
});
|
||||
|
||||
it('builds titles per source type matching the read-only calendar view', async () => {
|
||||
prisma.calendarSyncOutboxEntry.findMany.mockResolvedValue([
|
||||
{
|
||||
id: 'e1',
|
||||
sourceType: 'INSPECTION',
|
||||
sourceId: 'i1',
|
||||
graphEventId: null,
|
||||
attempts: 0,
|
||||
},
|
||||
]);
|
||||
prisma.inspection.findUnique.mockResolvedValue({
|
||||
id: 'i1',
|
||||
date: new Date('2026-03-07'),
|
||||
colony: { code: 'V-001' },
|
||||
});
|
||||
graphClient.createEvent.mockResolvedValue('g1');
|
||||
|
||||
await service.processOutbox();
|
||||
|
||||
expect(graphClient.createEvent).toHaveBeenCalledWith('group-1', {
|
||||
title: 'Durchsicht V-001',
|
||||
date: new Date('2026-03-07'),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getStatus', () => {
|
||||
it('reports configured/enabled/groupId plus outbox counts', async () => {
|
||||
prisma.calendarSyncOutboxEntry.count
|
||||
.mockResolvedValueOnce(2) // PENDING
|
||||
.mockResolvedValueOnce(1) // FAILED
|
||||
.mockResolvedValueOnce(5); // SYNCED
|
||||
|
||||
const status = await service.getStatus();
|
||||
|
||||
expect(status).toEqual({
|
||||
configured: true,
|
||||
enabled: true,
|
||||
groupId: 'group-1',
|
||||
pendingCount: 2,
|
||||
failedCount: 1,
|
||||
syncedCount: 5,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user