import { GraphClientService, GraphRequestError } 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.OIDC_ISSUER_URL = 'https://login.microsoftonline.com/tenant-1/v2.0'; process.env.OIDC_CLIENT_ID = 'client-1'; process.env.OIDC_CLIENT_SECRET = 'secret-1'; }); afterEach(() => { delete process.env.OIDC_ISSUER_URL; delete process.env.OIDC_CLIENT_ID; delete process.env.OIDC_CLIENT_SECRET; }); describe('isConfigured', () => { it('returns true when all three SSO env vars are set', () => { expect(service.isConfigured()).toBe(true); }); it('returns false when a required env var is missing', () => { delete process.env.OIDC_CLIENT_SECRET; expect(service.isConfigured()).toBe(false); }); it('returns false when the issuer URL has no tenant segment', () => { process.env.OIDC_ISSUER_URL = 'https://login.microsoftonline.com/'; expect(service.isConfigured()).toBe(false); }); }); describe('createEvent', () => { it('fetches a token from the tenant derived from OIDC_ISSUER_URL, then posts an all-day event', 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)) }, 'tx-durchsicht', ); expect(result).toBe('graph-event-1'); expect(fetchMock).toHaveBeenCalledTimes(2); const [tokenUrl, tokenInit] = fetchMock.mock.calls[0] as [ string, { body: URLSearchParams }, ]; expect(tokenUrl).toBe( 'https://login.microsoftonline.com/tenant-1/oauth2/v2.0/token', ); expect(tokenInit.body.get('client_id')).toBe('client-1'); expect(tokenInit.body.get('client_secret')).toBe('secret-1'); const [eventUrl, eventInit] = fetchMock.mock.calls[1] as [ string, { headers: Record; 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 }; transactionId: string; }; expect(body.subject).toBe('Durchsicht V-001'); // Lets Graph drop a repeated create after a timed-out first attempt. expect(body.transactionId).toBe('tx-durchsicht'); 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)) }, 'tx-a', ); await service.createEvent( 'group-1', { title: 'B', date: new Date(Date.UTC(2026, 2, 8)) }, 'tx-b', ); 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)) }, 'tx-a', ), ).rejects.toThrow(/createEvent fehlgeschlagen \(403\)/); }); }); describe('updateEvent', () => { it('never sends a transactionId, which Graph does not let an update change', async () => { fetchMock .mockResolvedValueOnce( jsonResponse(200, { access_token: 'tok-1', expires_in: 3600 }), ) .mockResolvedValueOnce(jsonResponse(200, {})); await service.updateEvent('group-1', 'event-1', { title: 'A', date: new Date(Date.UTC(2026, 2, 7)), }); const [, init] = fetchMock.mock.calls[1] as [string, { body: string }]; expect(JSON.parse(init.body)).not.toHaveProperty('transactionId'); }); it('rejects with a GraphRequestError carrying the HTTP status', async () => { fetchMock .mockResolvedValueOnce( jsonResponse(200, { access_token: 'tok-1', expires_in: 3600 }), ) .mockResolvedValueOnce(jsonResponse(404, { error: 'NotFound' })); const update = service.updateEvent('group-1', 'event-1', { title: 'A', date: new Date(Date.UTC(2026, 2, 7)), }); await expect(update).rejects.toBeInstanceOf(GraphRequestError); await expect(update).rejects.toMatchObject({ status: 404 }); }); }); // The outbox claim expires after a fixed time; a Graph call hanging longer // would let a second pod take over the same row while this one still works. it('bounds every request to Microsoft with a timeout', async () => { fetchMock .mockResolvedValueOnce( jsonResponse(200, { access_token: 'tok-1', expires_in: 3600 }), ) .mockResolvedValueOnce(jsonResponse(201, { id: 'event-1' })) .mockResolvedValueOnce(jsonResponse(200, {})) .mockResolvedValueOnce(jsonResponse(204, {})); const input = { title: 'A', date: new Date(Date.UTC(2026, 2, 7)) }; await service.createEvent('group-1', input, 'tx-1'); await service.updateEvent('group-1', 'event-1', input); await service.deleteEvent('group-1', 'event-1'); expect(fetchMock).toHaveBeenCalledTimes(4); for (const [, init] of fetchMock.mock.calls as [string, RequestInit][]) { expect(init.signal).toBeInstanceOf(AbortSignal); } }); // Ids end up in the URL path: a value with "/", "?" or "#" must not be // able to point the request at another Graph resource. it('encodes group and event ids in every request path', async () => { fetchMock .mockResolvedValueOnce( jsonResponse(200, { access_token: 'tok-1', expires_in: 3600 }), ) .mockResolvedValueOnce(jsonResponse(201, { id: 'event-1' })) .mockResolvedValueOnce(jsonResponse(200, {})) .mockResolvedValueOnce(jsonResponse(204, {})); const input = { title: 'A', date: new Date(Date.UTC(2026, 2, 7)) }; const groupId = '../users/x?$top=1#'; const eventId = 'AAMk/abc+def='; await service.createEvent(groupId, input, 'tx-1'); await service.updateEvent(groupId, eventId, input); await service.deleteEvent(groupId, eventId); const urls = (fetchMock.mock.calls as [string][]).map(([url]) => url); const group = '..%2Fusers%2Fx%3F%24top%3D1%23'; const event = 'AAMk%2Fabc%2Bdef%3D'; expect(urls.slice(1)).toEqual([ `https://graph.microsoft.com/v1.0/groups/${group}/events`, `https://graph.microsoft.com/v1.0/groups/${group}/events/${event}`, `https://graph.microsoft.com/v1.0/groups/${group}/events/${event}`, ]); }); 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(); }); }); });