feat(p8): simulated calendar adapter (provider · sync cursor · availability)
Task 8.5: CalendarAdapter connects a SIMULATED provider account + sync cursor, deterministically pulls stable external events (idempotent by externalEventId, advancing the cursor), records availability windows, and computes free/busy from overlapping calendar events. No network. Real provider via Capability Broker = P9. 3 tests: connect, sync idempotent + cursor advance, availability busy/free. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,45 @@
|
||||
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { resetDb } from '../test-utils/reset-db';
|
||||
import { type MessagePrincipal } from '../messaging/message.service';
|
||||
import { ActorResolver } from '../identity/actor.resolver';
|
||||
import { CalendarAdapter } from './calendar-adapter';
|
||||
import type { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
const url = process.env.DATABASE_URL ?? 'postgresql://iios:iios@localhost:5434/iios?schema=public';
|
||||
const prisma = new PrismaClient({ datasources: { db: { url } } });
|
||||
const asService = prisma as unknown as PrismaService;
|
||||
const actors = new ActorResolver(asService);
|
||||
const adapter = () => new CalendarAdapter(asService, actors);
|
||||
|
||||
const alice: MessagePrincipal = { userId: 'alice', orgId: 'org_demo', appId: 'portal-demo', displayName: 'Alice' };
|
||||
|
||||
beforeAll(async () => { await prisma.$connect(); });
|
||||
afterAll(async () => { await prisma.$disconnect(); });
|
||||
beforeEach(async () => { await resetDb(prisma); });
|
||||
|
||||
describe('CalendarAdapter (P8 — simulated, no network)', () => {
|
||||
it('connect → provider account + sync cursor', async () => {
|
||||
const acct = await adapter().connectProvider(alice);
|
||||
expect(acct.providerType).toBe('SIMULATED');
|
||||
const cursor = await prisma.iiosCalendarSyncCursor.findUniqueOrThrow({ where: { providerAccountId: acct.id } });
|
||||
expect(cursor.status).toBe('IDLE');
|
||||
});
|
||||
|
||||
it('syncOnce twice advances the cursor without duplicating events (idempotent)', async () => {
|
||||
const acct = await adapter().connectProvider(alice);
|
||||
const r1 = await adapter().syncOnce(acct.id);
|
||||
const r2 = await adapter().syncOnce(acct.id);
|
||||
expect(Number(r2.cursor)).toBeGreaterThan(Number(r1.cursor));
|
||||
expect(await prisma.iiosCalendarEvent.count({ where: { providerAccountId: acct.id } })).toBe(2);
|
||||
});
|
||||
|
||||
it('availability reflects a busy synced calendar event', async () => {
|
||||
const acct = await adapter().connectProvider(alice);
|
||||
await adapter().syncOnce(acct.id);
|
||||
const busy = await adapter().availability(alice, { startAt: '2026-07-02T09:15:00.000Z', endAt: '2026-07-02T09:45:00.000Z' });
|
||||
expect(busy.free).toBe(false);
|
||||
const free = await adapter().availability(alice, { startAt: '2026-07-02T18:00:00.000Z', endAt: '2026-07-02T18:30:00.000Z' });
|
||||
expect(free.free).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { ActorResolver, type MessagePrincipal } from '../identity/actor.resolver';
|
||||
|
||||
/**
|
||||
* Simulated calendar adapter (P8). Models the external-calendar sync shape the
|
||||
* Atlas calls for — provider account, incremental sync cursor, external event ids
|
||||
* — deterministically and with NO network. A real provider (Google/Outlook/Zoom)
|
||||
* via the Capability Broker replaces this in P9.
|
||||
*/
|
||||
@Injectable()
|
||||
export class CalendarAdapter {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly actors: ActorResolver,
|
||||
) {}
|
||||
|
||||
async connectProvider(principal: MessagePrincipal) {
|
||||
const scope = await this.actors.resolveScope(principal);
|
||||
const actor = await this.actors.resolveActor(scope.id, principal);
|
||||
const account = await this.prisma.iiosCalendarProviderAccount.create({
|
||||
data: { scopeId: scope.id, actorRefId: actor.id, providerType: 'SIMULATED', accountHandleToken: `sim:${actor.id}` },
|
||||
});
|
||||
await this.prisma.iiosCalendarSyncCursor.create({ data: { providerAccountId: account.id, status: 'IDLE' } });
|
||||
return account;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deterministically "pull" a stable set of external events (idempotent by
|
||||
* externalEventId) and advance the cursor. Re-running never duplicates events.
|
||||
*/
|
||||
async syncOnce(providerAccountId: string) {
|
||||
const account = await this.prisma.iiosCalendarProviderAccount.findUnique({ where: { id: providerAccountId } });
|
||||
if (!account) throw new NotFoundException('provider account not found');
|
||||
|
||||
const fixtures = [
|
||||
{ externalEventId: `ext-${providerAccountId}-standup`, title: 'Daily standup', startAt: '2026-07-02T09:00:00.000Z', endAt: '2026-07-02T09:30:00.000Z' },
|
||||
{ externalEventId: `ext-${providerAccountId}-review`, title: 'Design review', startAt: '2026-07-02T14:00:00.000Z', endAt: '2026-07-02T15:00:00.000Z' },
|
||||
];
|
||||
for (const f of fixtures) {
|
||||
await this.prisma.iiosCalendarEvent.upsert({
|
||||
where: { providerAccountId_externalEventId: { providerAccountId, externalEventId: f.externalEventId } },
|
||||
create: {
|
||||
scopeId: account.scopeId,
|
||||
providerAccountId,
|
||||
externalEventId: f.externalEventId,
|
||||
title: f.title,
|
||||
startAt: new Date(f.startAt),
|
||||
endAt: new Date(f.endAt),
|
||||
status: 'CONFIRMED',
|
||||
},
|
||||
update: { title: f.title },
|
||||
});
|
||||
}
|
||||
|
||||
const cursor = await this.prisma.iiosCalendarSyncCursor.findUniqueOrThrow({ where: { providerAccountId } });
|
||||
const next = String(Number(cursor.externalCursor ?? '0') + 1);
|
||||
await this.prisma.iiosCalendarSyncCursor.update({
|
||||
where: { providerAccountId },
|
||||
data: { externalCursor: next, lastSyncAt: new Date(), status: 'IDLE' },
|
||||
});
|
||||
return { pulled: fixtures.length, cursor: next };
|
||||
}
|
||||
|
||||
async setAvailability(principal: MessagePrincipal, input: { startAt: string; endAt: string; status?: string; source?: string }) {
|
||||
const scope = await this.actors.resolveScope(principal);
|
||||
const actor = await this.actors.resolveActor(scope.id, principal);
|
||||
return this.prisma.iiosAvailabilityWindow.create({
|
||||
data: {
|
||||
scopeId: scope.id,
|
||||
actorRefId: actor.id,
|
||||
startAt: new Date(input.startAt),
|
||||
endAt: new Date(input.endAt),
|
||||
source: input.source ?? 'USER',
|
||||
status: input.status ?? 'ACTIVE',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Free/busy over a window: busy if any confirmed calendar event in scope overlaps. */
|
||||
async availability(principal: MessagePrincipal, window: { startAt: string; endAt: string }) {
|
||||
const scope = await this.actors.resolveScope(principal);
|
||||
const start = new Date(window.startAt);
|
||||
const end = new Date(window.endAt);
|
||||
const conflicts = await this.prisma.iiosCalendarEvent.findMany({
|
||||
where: { scopeId: scope.id, status: 'CONFIRMED', startAt: { lt: end }, endAt: { gt: start } },
|
||||
});
|
||||
return { free: conflicts.length === 0, conflicts };
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { AdaptersModule } from '../adapters/adapters.module';
|
||||
import { AiModule } from '../ai/ai.module';
|
||||
import { CalendarService } from './calendar.service';
|
||||
import { CalendarProjector } from './calendar.projector';
|
||||
import { CalendarAdapter } from './calendar-adapter';
|
||||
|
||||
/**
|
||||
* Calendar & Meeting SDK (P8). Imports OutboxModule (projector/events),
|
||||
@@ -12,7 +13,7 @@ import { CalendarProjector } from './calendar.projector';
|
||||
*/
|
||||
@Module({
|
||||
imports: [OutboxModule, AdaptersModule, AiModule],
|
||||
providers: [CalendarService, CalendarProjector],
|
||||
exports: [CalendarService],
|
||||
providers: [CalendarService, CalendarProjector, CalendarAdapter],
|
||||
exports: [CalendarService, CalendarAdapter],
|
||||
})
|
||||
export class CalendarModule {}
|
||||
|
||||
Reference in New Issue
Block a user