Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions .agents/handoffs/3246.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
---
schema_version: 1
task_id: "3246"
from: Implementer
to: GitHub
owner: GitHub
status: verifying
artifact:
- path: packages/sync/src/providers/microsoft/microsoft-event-writer.adapter.ts
- path: packages/sync/src/providers/microsoft/microsoft-event-writer.adapter.test.ts
- path: packages/sync/src/providers/microsoft/microsoft-event.normalizer.ts
- path: packages/sync/src/providers/provider-event-writer.port.ts
- path: packages/sync/src/providers/__contract__/microsoft-contract.factory.ts
- path: packages/sync/src/providers/__contract__/fixtures/microsoft/writer.json
- path: packages/sync/src/providers/__contract__/microsoft.contract.test.ts
evidence:
- command: bun run verify --strict
result: "VERDICT: PASS (test:sync:fast, type-check, lint, knip)"
assumptions:
- "Meeting-provider cache is per writer instance, keyed by access token, TTL 5 minutes."
- "Graph /instances occurrence rows are rewritten to exception before M-04 so the delta reader can keep skipping expansions."
- "conference: null on createConference when no Teams provider is allowed; omitted when the write did not ask for a conference."
open_risks: []
next_deadline: 2026-09-05T12:00:00Z
retry: 0
approval: allow
waiting_on: null
escalation: null
---

M WP-06b: Graph writer creates Teams conferences when asked and resolves
recurring instances by originalStart.
Original file line number Diff line number Diff line change
Expand Up @@ -33,5 +33,22 @@
"timeZone": "UTC"
},
"showAs": "busy"
},
"instance": {
"id": "series-1_instance",
"@odata.etag": "W/\"graph-instance-etag\"",
"type": "occurrence",
"subject": "Contract instance",
"seriesMasterId": "series-1",
"originalStart": "2025-01-15T14:00:00.0000000Z",
"start": {
"dateTime": "2025-01-15T14:00:00.0000000",
"timeZone": "UTC"
},
"end": {
"dateTime": "2025-01-15T15:00:00.0000000",
"timeZone": "UTC"
},
"showAs": "busy"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ export interface MicrosoftReaderCorpus {
interface WriterCorpus {
readonly create: GraphEvent;
readonly fetch: GraphEvent;
readonly instance: GraphEvent;
}

class CorpusEventListApi implements MicrosoftEventListApi {
Expand Down Expand Up @@ -122,6 +123,25 @@ class CorpusEventWriteApi implements MicrosoftEventWriteApi {
"@odata.etag": this.#etag,
};
}

async getCalendar() {
return {
allowedOnlineMeetingProviders: [] as const,
defaultOnlineMeetingProvider: "unknown",
};
}

async listInstances(
params: Parameters<MicrosoftEventWriteApi["listInstances"]>[0],
): Promise<readonly GraphEvent[]> {
if (
params.seriesEventId !==
(this.corpus.instance.seriesMasterId ?? "series-1")
) {
return [];
}
return [this.corpus.instance];
}
}

/** Replay `fixtures/microsoft/reader.json` through the event reader adapter. */
Expand Down
23 changes: 10 additions & 13 deletions packages/sync/src/providers/__contract__/microsoft.contract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,6 @@ import {
type ProviderEventReadError,
type ProviderEventReader,
} from "@sync/providers/provider-event-reader.port";
import { ProviderWriteError } from "@sync/providers/provider-event-writer.port";

const CLIENT_ID = "microsoft-client-id";
const CLIENT_SECRET = "microsoft-client-secret";
Expand Down Expand Up @@ -379,18 +378,16 @@ describe("microsoft writer contract", () => {
});
});

it("fetchInstanceAt remains unsupported until M-06b", async () => {
const error = await writerAdapter
.fetchInstanceAt({
accessToken: "contract-access-token",
calendarId: "primary",
seriesProviderEventId: "series-1",
originalStartAt: "2025-01-15T14:00:00.000Z",
scheduleKind: "timed",
})
.catch((caught) => caught);
expect(error).toBeInstanceOf(ProviderWriteError);
expect((error as ProviderWriteError).reason).toBe("unsupportedCapability");
it("fetchInstanceAt resolves an occurrence by originalStart", async () => {
const instance = await writerAdapter.fetchInstanceAt({
accessToken: "contract-access-token",
calendarId: "primary",
seriesProviderEventId: "series-1",
originalStartAt: "2025-01-15T14:00:00.000Z",
scheduleKind: "timed",
});
expect(instance).not.toBeNull();
expect(instance?.providerEventId.length).toBeGreaterThan(0);
});
});

Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { type EventSchedule } from "@core/types/event.contracts";
import { type SyncEventContent } from "@core/types/sync/event.contracts";
import { type GraphEvent } from "@sync/providers/microsoft/microsoft-event.normalizer";
import {
type GraphEvent,
type GraphCalendarMeetingInfo,
type MicrosoftEventWriteApi,
MicrosoftEventWriter,
} from "@sync/providers/microsoft/microsoft-event-writer.adapter";
Expand All @@ -26,13 +27,19 @@ const scriptedEvent = (id: string, etag = 'W/"graph-v1"'): GraphEvent => ({
});

type Behavior = GraphEvent | Error | undefined;
type CalendarBehavior = GraphCalendarMeetingInfo | Error | undefined;
type InstancesBehavior = readonly GraphEvent[] | Error | undefined;

class FakeWriteApi implements MicrosoftEventWriteApi {
calls = {
create: [] as Parameters<MicrosoftEventWriteApi["create"]>[0][],
patch: [] as Parameters<MicrosoftEventWriteApi["patch"]>[0][],
delete: [] as Parameters<MicrosoftEventWriteApi["delete"]>[0][],
get: [] as Parameters<MicrosoftEventWriteApi["get"]>[0][],
getCalendar: 0,
listInstances: [] as Parameters<
MicrosoftEventWriteApi["listInstances"]
>[0][],
};

#etag: string;
Expand All @@ -45,6 +52,8 @@ class FakeWriteApi implements MicrosoftEventWriteApi {
patch?: Behavior;
delete?: Behavior;
get?: Behavior;
getCalendar?: CalendarBehavior;
listInstances?: InstancesBehavior;
} = {},
initialEtag = 'W/"graph-v1"',
) {
Expand Down Expand Up @@ -97,6 +106,27 @@ class FakeWriteApi implements MicrosoftEventWriteApi {
);
}

async getCalendar(): Promise<GraphCalendarMeetingInfo> {
this.calls.getCalendar += 1;
const scripted = this.behavior.getCalendar;
if (scripted instanceof Error) throw scripted;
return (
scripted ?? {
allowedOnlineMeetingProviders: [],
defaultOnlineMeetingProvider: "unknown",
}
);
}

async listInstances(
params: Parameters<MicrosoftEventWriteApi["listInstances"]>[0],
): Promise<readonly GraphEvent[]> {
this.calls.listInstances.push(params);
const scripted = this.behavior.listInstances;
if (scripted instanceof Error) throw scripted;
return scripted ?? [];
}

#settle(method: "create" | "patch" | "get", fallback: () => GraphEvent) {
const scripted = this.behavior[method];
if (scripted instanceof Error) throw scripted;
Expand Down Expand Up @@ -432,20 +462,186 @@ describe("MicrosoftEventWriter", () => {
expect(missing).toBeNull();
});

it("fetchInstanceAt is unsupported until M-06b", async () => {
const { writer } = writerWith(new FakeWriteApi());
it("does not read meeting providers unless createConference is set", async () => {
const api = new FakeWriteApi();
await writerWith(api).writer.createEvent(baseCreate);
expect(api.calls.getCalendar).toBe(0);
expect(api.calls.create[0]?.body).not.toHaveProperty("isOnlineMeeting");
});

const error = (await writer
.fetchInstanceAt({
accessToken: "at",
calendarId: "cal",
seriesProviderEventId: "series-1",
originalStartAt: "2025-01-15T14:00:00.000Z",
scheduleKind: "timed",
})
.catch((e) => e)) as ProviderWriteError;
it("uses the default Teams provider when creating a conference", async () => {
const api = new FakeWriteApi({
getCalendar: {
allowedOnlineMeetingProviders: ["teamsForBusiness", "skypeForConsumer"],
defaultOnlineMeetingProvider: "teamsForBusiness",
},
create: {
...scriptedEvent("abc12deadbeef00000000000"),
onlineMeeting: {
joinUrl: "https://teams.microsoft.com/l/meetup-join/abc",
},
onlineMeetingProvider: "teamsForBusiness",
},
});

const result = await writerWith(api).writer.createEvent({
...baseCreate,
createConference: true,
});

expect(api.calls.create[0]?.body).toMatchObject({
isOnlineMeeting: true,
onlineMeetingProvider: "teamsForBusiness",
});
expect(result.conference).toEqual({
url: "https://teams.microsoft.com/l/meetup-join/abc",
label: "Microsoft Teams",
});
});

it("falls back to the first allowed Teams provider when the default is not Teams", async () => {
const api = new FakeWriteApi({
getCalendar: {
allowedOnlineMeetingProviders: ["skypeForBusiness", "teamsForConsumer"],
defaultOnlineMeetingProvider: "skypeForBusiness",
},
create: {
...scriptedEvent("abc12deadbeef00000000000"),
onlineMeeting: {
joinUrl: "https://teams.microsoft.com/l/meetup-join/consumer",
},
onlineMeetingProvider: "teamsForConsumer",
},
});

const result = await writerWith(api).writer.createEvent({
...baseCreate,
accessToken: "token-teams-allowed",
createConference: true,
});

expect(api.calls.create[0]?.body).toMatchObject({
isOnlineMeeting: true,
onlineMeetingProvider: "teamsForConsumer",
});
expect(result.conference).toEqual({
url: "https://teams.microsoft.com/l/meetup-join/consumer",
label: "Microsoft Teams",
});
});

it("creates without a conference when no Teams provider is allowed", async () => {
const api = new FakeWriteApi({
getCalendar: {
allowedOnlineMeetingProviders: ["skypeForConsumer"],
defaultOnlineMeetingProvider: "skypeForConsumer",
},
});

const result = await writerWith(api).writer.createEvent({
...baseCreate,
accessToken: "token-no-teams",
createConference: true,
});

expect(api.calls.create[0]?.body).not.toHaveProperty("isOnlineMeeting");
expect(api.calls.create[0]?.body).not.toHaveProperty(
"onlineMeetingProvider",
);
expect(result.conference).toBeNull();
});

it("reads meeting providers once per access token within the cache TTL", async () => {
const api = new FakeWriteApi({
getCalendar: {
allowedOnlineMeetingProviders: ["teamsForBusiness"],
defaultOnlineMeetingProvider: "teamsForBusiness",
},
});
const { writer } = writerWith(api);

await writer.createEvent({ ...baseCreate, createConference: true });
await writer.createEvent({
...baseCreate,
providerEventId: "def12deadbeef00000000000",
createConference: true,
});

expect(api.calls.getCalendar).toBe(1);
});

it("resolves an occurrence whose originalStart matches the requested instant", async () => {
const api = new FakeWriteApi({
listInstances: [
{
...scriptedEvent("series-1_instance"),
type: "occurrence",
seriesMasterId: "series-1",
originalStart: "2025-01-15T14:00:00.0000000Z",
},
],
});
const { writer } = writerWith(api);

const read = await writer.fetchInstanceAt({
accessToken: "at",
calendarId: "cal",
seriesProviderEventId: "series-1",
originalStartAt: "2025-01-15T14:00:00.000Z",
scheduleKind: "timed",
});

expect(api.calls.listInstances[0]).toEqual({
seriesEventId: "series-1",
startDateTime: "2025-01-15T00:00:00.000",
endDateTime: "2025-01-16T00:00:00.000",
});
expect(read?.kind).toBe("event");
expect(read?.providerEventId).toBe("series-1_instance");
if (read?.kind === "event") {
expect(read.recurrence).toEqual({
kind: "instance",
seriesProviderId: "series-1",
recurrenceId: "2025-01-15T14:00:00.000Z",
});
}
});

it("returns null when no instance exists at that instant", async () => {
const api = new FakeWriteApi({
listInstances: [
{
...scriptedEvent("other-instance"),
type: "occurrence",
seriesMasterId: "series-1",
originalStart: "2025-01-16T14:00:00.0000000Z",
},
],
});

const read = await writerWith(api).writer.fetchInstanceAt({
accessToken: "at",
calendarId: "cal",
seriesProviderEventId: "series-1",
originalStartAt: "2025-01-15T14:00:00.000Z",
scheduleKind: "timed",
});

expect(read).toBeNull();
});

it("returns null when the series itself is gone", async () => {
const api = new FakeWriteApi({ listInstances: msError(404) });

const read = await writerWith(api).writer.fetchInstanceAt({
accessToken: "at",
calendarId: "cal",
seriesProviderEventId: "gone",
originalStartAt: "2025-01-15T14:00:00.000Z",
scheduleKind: "timed",
});

expect(error.reason).toBe("unsupportedCapability");
expect(read).toBeNull();
});

it("never leaks the bearer token onto a thrown error cause", async () => {
Expand Down
Loading