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,130 @@
|
||||
import { GraphClientService } from './graph-client.service';
|
||||
|
||||
function jsonResponse(status: number, body: unknown): Response {
|
||||
return {
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
json: () => Promise.resolve(body),
|
||||
text: () => Promise.resolve(JSON.stringify(body)),
|
||||
} as Response;
|
||||
}
|
||||
|
||||
describe('GraphClientService', () => {
|
||||
let service: GraphClientService;
|
||||
let fetchMock: jest.Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
service = new GraphClientService();
|
||||
fetchMock = jest.fn();
|
||||
global.fetch = fetchMock;
|
||||
process.env.M365_CALENDAR_TENANT_ID = 'tenant-1';
|
||||
process.env.M365_CALENDAR_CLIENT_ID = 'client-1';
|
||||
process.env.M365_CALENDAR_CLIENT_SECRET = 'secret-1';
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env.M365_CALENDAR_TENANT_ID;
|
||||
delete process.env.M365_CALENDAR_CLIENT_ID;
|
||||
delete process.env.M365_CALENDAR_CLIENT_SECRET;
|
||||
});
|
||||
|
||||
describe('isConfigured', () => {
|
||||
it('returns true when all three env vars are set', () => {
|
||||
expect(service.isConfigured()).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when a required env var is missing', () => {
|
||||
delete process.env.M365_CALENDAR_CLIENT_SECRET;
|
||||
expect(service.isConfigured()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createEvent', () => {
|
||||
it('fetches a token, then posts an all-day event and returns the new id', async () => {
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(
|
||||
jsonResponse(200, { access_token: 'tok-1', expires_in: 3600 }),
|
||||
)
|
||||
.mockResolvedValueOnce(jsonResponse(201, { id: 'graph-event-1' }));
|
||||
|
||||
const result = await service.createEvent('group-1', {
|
||||
title: 'Durchsicht V-001',
|
||||
date: new Date(Date.UTC(2026, 2, 7)),
|
||||
});
|
||||
|
||||
expect(result).toBe('graph-event-1');
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
const [tokenUrl] = fetchMock.mock.calls[0] as [string];
|
||||
expect(tokenUrl).toBe(
|
||||
'https://login.microsoftonline.com/tenant-1/oauth2/v2.0/token',
|
||||
);
|
||||
const [eventUrl, eventInit] = fetchMock.mock.calls[1] as [
|
||||
string,
|
||||
{ headers: Record<string, string>; body: string },
|
||||
];
|
||||
expect(eventUrl).toBe(
|
||||
'https://graph.microsoft.com/v1.0/groups/group-1/events',
|
||||
);
|
||||
expect(eventInit.headers.Authorization).toBe('Bearer tok-1');
|
||||
const body = JSON.parse(eventInit.body) as {
|
||||
subject: string;
|
||||
isAllDay: boolean;
|
||||
start: { dateTime: string };
|
||||
end: { dateTime: string };
|
||||
};
|
||||
expect(body.subject).toBe('Durchsicht V-001');
|
||||
expect(body.isAllDay).toBe(true);
|
||||
expect(body.start.dateTime).toBe('2026-03-07T00:00:00.0000000');
|
||||
expect(body.end.dateTime).toBe('2026-03-08T00:00:00.0000000');
|
||||
});
|
||||
|
||||
it('caches the token and does not fetch it again for a second call', async () => {
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(
|
||||
jsonResponse(200, { access_token: 'tok-1', expires_in: 3600 }),
|
||||
)
|
||||
.mockResolvedValueOnce(jsonResponse(201, { id: 'event-a' }))
|
||||
.mockResolvedValueOnce(jsonResponse(201, { id: 'event-b' }));
|
||||
|
||||
await service.createEvent('group-1', {
|
||||
title: 'A',
|
||||
date: new Date(Date.UTC(2026, 2, 7)),
|
||||
});
|
||||
await service.createEvent('group-1', {
|
||||
title: 'B',
|
||||
date: new Date(Date.UTC(2026, 2, 8)),
|
||||
});
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('throws with the response body when Graph returns an error status', async () => {
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(
|
||||
jsonResponse(200, { access_token: 'tok-1', expires_in: 3600 }),
|
||||
)
|
||||
.mockResolvedValueOnce(jsonResponse(403, { error: 'Forbidden' }));
|
||||
|
||||
await expect(
|
||||
service.createEvent('group-1', {
|
||||
title: 'A',
|
||||
date: new Date(Date.UTC(2026, 2, 7)),
|
||||
}),
|
||||
).rejects.toThrow(/createEvent fehlgeschlagen \(403\)/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteEvent', () => {
|
||||
it('treats a 404 as success (idempotent delete)', async () => {
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(
|
||||
jsonResponse(200, { access_token: 'tok-1', expires_in: 3600 }),
|
||||
)
|
||||
.mockResolvedValueOnce(jsonResponse(404, {}));
|
||||
|
||||
await expect(
|
||||
service.deleteEvent('group-1', 'missing-event'),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user