refactor(o365): extract authenticated graph client helper, add getEventById validation

Extract buildAuthenticatedClient() helper to eliminate duplicate MSAL token
acquisition logic in getCalendarEvents and getEventById. Add eventId validation
and error wrapping with cause chain in getEventById.

New tests verify eventId type and empty-string checks.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
beo3000 2026-05-06 21:03:30 +02:00
parent 997e9411b9
commit 51613ffe12
2 changed files with 46 additions and 22 deletions

View File

@ -58,7 +58,7 @@ function formatEventChoice(meeting) {
return `📅 ${meeting.date} ${meeting.start}-${meeting.end} ${meeting.title}`; return `📅 ${meeting.date} ${meeting.start}-${meeting.end} ${meeting.title}`;
} }
async function getCalendarEvents(daysAhead = 7) { async function buildAuthenticatedClient() {
const env = loadEnv(); const env = loadEnv();
const cca = new ConfidentialClientApplication({ const cca = new ConfidentialClientApplication({
@ -73,9 +73,14 @@ async function getCalendarEvents(daysAhead = 7) {
scopes: ['https://graph.microsoft.com/.default'] scopes: ['https://graph.microsoft.com/.default']
}); });
const client = Client.init({ return Client.init({
authProvider: (done) => done(null, tokenResponse.accessToken) authProvider: (done) => done(null, tokenResponse.accessToken)
}); });
}
async function getCalendarEvents(daysAhead = 7) {
const env = loadEnv();
const client = await buildAuthenticatedClient();
const now = new Date(); const now = new Date();
const future = new Date(now); const future = new Date(now);
@ -102,26 +107,22 @@ function extractJoinUrlFromBody(body) {
} }
async function getEventById(eventId) { async function getEventById(eventId) {
const env = loadEnv(); if (typeof eventId !== 'string' || !eventId.trim()) {
const cca = new ConfidentialClientApplication({ throw new TypeError('eventId must be a non-empty string');
auth: {
clientId: env.AZURE_CLIENT_ID,
clientSecret: env.AZURE_CLIENT_SECRET,
authority: `https://login.microsoftonline.com/${env.AZURE_TENANT_ID}`
} }
});
const tokenResponse = await cca.acquireTokenByClientCredential({ try {
scopes: ['https://graph.microsoft.com/.default'] const env = loadEnv();
}); const client = await buildAuthenticatedClient();
const client = Client.init({
authProvider: (done) => done(null, tokenResponse.accessToken)
});
const event = await client const event = await client
.api(`/users/${env.AZURE_USER_EMAIL}/events/${eventId}`) .api(`/users/${env.AZURE_USER_EMAIL}/events/${eventId}`)
.select('id,subject,start,end,body,attendees,seriesMasterId,onlineMeeting') .select('id,subject,start,end,body,attendees,seriesMasterId,onlineMeeting')
.get(); .get();
return event; return event;
} catch (cause) {
throw new Error(`Failed to fetch event ${eventId}: ${cause.message}`, { cause });
}
} }
module.exports = { module.exports = {

View File

@ -81,7 +81,7 @@ describe('isRecurring', () => {
}); });
}); });
const { extractJoinUrlFromBody } = require('../lib/o365-calendar.js'); const { extractJoinUrlFromBody, getEventById } = require('../lib/o365-calendar.js');
describe('extractJoinUrlFromBody', () => { describe('extractJoinUrlFromBody', () => {
it('extracts teams meeting join URL from body html', () => { it('extracts teams meeting join URL from body html', () => {
@ -96,3 +96,26 @@ describe('extractJoinUrlFromBody', () => {
assert.equal(extractJoinUrlFromBody('<p>nothing</p>'), null); assert.equal(extractJoinUrlFromBody('<p>nothing</p>'), null);
}); });
}); });
describe('getEventById', () => {
it('throws TypeError when eventId is empty string', async () => {
await assert.rejects(
() => getEventById(''),
{ name: 'TypeError', message: /eventId must be a non-empty string/ }
);
});
it('throws TypeError when eventId is not a string', async () => {
await assert.rejects(
() => getEventById(null),
{ name: 'TypeError', message: /eventId must be a non-empty string/ }
);
});
it('throws TypeError when eventId is whitespace only', async () => {
await assert.rejects(
() => getEventById(' '),
{ name: 'TypeError', message: /eventId must be a non-empty string/ }
);
});
});