a933e2e3af
OutboundService.send is now opt-in idempotent: an explicit idempotencyKey routes the send through IdempotencyService.run (Slice 6), so reusing a key with a different target/payload returns a 409 conflict and a same-key/same-request retry replays the cached command. The inner findUnique early-return stays as a backstop so a FAILED-then-retried send never collides on the command's unique key. Keyless sends keep the old behavior (no ledger row). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
102 lines
5.3 KiB
TypeScript
102 lines
5.3 KiB
TypeScript
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
|
|
import { PrismaClient } from '@prisma/client';
|
|
import { resetDb } from '../test-utils/reset-db';
|
|
import { makeFakePorts, makeFakeInference } from '@insignia/iios-testkit';
|
|
import { type MessagePrincipal } from '../messaging/message.service';
|
|
import { ActorResolver } from '../identity/actor.resolver';
|
|
import { AiJobService } from '../ai/ai.service';
|
|
import { AiBudgetGuard } from '../ai/ai-budget.guard';
|
|
import { OutboundService } from '../adapters/outbound.service';
|
|
import { IdempotencyService } from '../idempotency/idempotency.service';
|
|
import { CapabilityBroker } from '../capability/capability.broker';
|
|
import { CapabilityProviderRegistry } from '../capability/capability.registry';
|
|
import { CalendarService } from './calendar.service';
|
|
import type { PrismaService } from '../prisma/prisma.service';
|
|
import type { FakePorts } from '@insignia/iios-testkit';
|
|
|
|
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 alice: MessagePrincipal = { userId: 'alice', orgId: 'org_demo', appId: 'portal-demo', displayName: 'Alice' };
|
|
const START = '2026-07-02T10:00:00.000Z';
|
|
|
|
const ai = () => new AiJobService(asService, makeFakePorts(), makeFakeInference(), new AiBudgetGuard(asService), actors);
|
|
const cal = (ports?: FakePorts) => new CalendarService(asService, ports ?? makeFakePorts(), actors, ai(), new OutboundService(asService, new CapabilityBroker(makeFakePorts(), new CapabilityProviderRegistry()), new IdempotencyService(asService)));
|
|
|
|
async function scheduledMeeting(attendees: { userId: string; visibility?: 'FULL' | 'NONE' }[]) {
|
|
const m = await cal().scheduleDirect(alice, { meetingType: 'INTERNAL', title: 'Board sync', startAt: START, attendees });
|
|
return m;
|
|
}
|
|
const grantAll = async (meetingId: string) => {
|
|
const ps = await prisma.iiosMeetingParticipant.findMany({ where: { meetingId } });
|
|
for (const p of ps) if (p.actorRefId) await cal().setConsent(meetingId, p.actorRefId, 'GRANTED');
|
|
};
|
|
|
|
beforeAll(async () => { await prisma.$connect(); });
|
|
afterAll(async () => { await prisma.$disconnect(); });
|
|
beforeEach(async () => { await resetDb(prisma); });
|
|
|
|
describe('Calendar consent gate (P8 — KG-14 / Scenario 6)', () => {
|
|
it('transcript is BLOCKED with no segments when an attendee has not consented (KG-14)', async () => {
|
|
const m = await scheduledMeeting([{ userId: 'bob' }]);
|
|
// organizer + bob; nobody granted consent
|
|
const t = await cal().generateTranscript(m.id, alice);
|
|
expect(t.status).toBe('BLOCKED');
|
|
expect(await prisma.iiosTranscriptSegment.count()).toBe(0);
|
|
expect(await prisma.iiosMeetingSummary.count()).toBe(0);
|
|
expect(await prisma.iiosMeetingActionItem.count()).toBe(0);
|
|
});
|
|
|
|
it('transcript is READY with segments once every attendee consents', async () => {
|
|
const m = await scheduledMeeting([{ userId: 'bob' }]);
|
|
await grantAll(m.id);
|
|
const t = await cal().generateTranscript(m.id, alice);
|
|
expect(t.status).toBe('READY');
|
|
expect(t.segments.length).toBe(2);
|
|
});
|
|
|
|
it('CMP DENY blocks the transcript even if all attendees granted', async () => {
|
|
const m = await scheduledMeeting([{ userId: 'bob' }]);
|
|
await grantAll(m.id);
|
|
const ports = makeFakePorts();
|
|
ports.cmp.setStatus('DENY');
|
|
const t = await cal(ports).generateTranscript(m.id, alice);
|
|
expect(t.status).toBe('BLOCKED');
|
|
});
|
|
|
|
it('summarize → summary linked to a P7 ai_artifact + action items + MEETING_FOLLOWUP inbox', async () => {
|
|
const m = await scheduledMeeting([{ userId: 'bob' }]);
|
|
await grantAll(m.id);
|
|
await cal().generateTranscript(m.id, alice);
|
|
await cal().summarize(m.id, alice);
|
|
|
|
const summary = await prisma.iiosMeetingSummary.findFirstOrThrow({ where: { meetingId: m.id } });
|
|
expect(summary.aiArtifactId).toBeTruthy();
|
|
const artifact = await prisma.iiosAiArtifact.findUniqueOrThrow({ where: { id: summary.aiArtifactId! } });
|
|
expect(artifact.artifactType).toBe('SUMMARY');
|
|
expect(await prisma.iiosMeetingActionItem.count({ where: { meetingId: m.id } })).toBeGreaterThan(0);
|
|
expect(await prisma.iiosInboxItem.count({ where: { kind: 'MEETING_FOLLOWUP' } })).toBe(2); // organizer + bob
|
|
});
|
|
|
|
it('a visibility=NONE attendee is excluded from inbox follow-ups (Scenario 6)', async () => {
|
|
const m = await scheduledMeeting([{ userId: 'bob', visibility: 'NONE' }]);
|
|
await grantAll(m.id);
|
|
await cal().generateTranscript(m.id, alice);
|
|
await cal().summarize(m.id, alice);
|
|
// only the organizer (FULL) gets a follow-up; bob (NONE) is excluded
|
|
expect(await prisma.iiosInboxItem.count({ where: { kind: 'MEETING_FOLLOWUP' } })).toBe(1);
|
|
});
|
|
|
|
it('revoking consent after a READY transcript blocks re-summary', async () => {
|
|
const m = await scheduledMeeting([{ userId: 'bob' }]);
|
|
await grantAll(m.id);
|
|
await cal().generateTranscript(m.id, alice);
|
|
const bob = await prisma.iiosMeetingParticipant.findFirstOrThrow({ where: { meetingId: m.id, role: 'REQUIRED' } });
|
|
await cal().setConsent(m.id, bob.actorRefId!, 'REVOKED');
|
|
await expect(cal().summarize(m.id, alice)).rejects.toThrow();
|
|
expect(await prisma.iiosMeetingSummary.count()).toBe(0);
|
|
});
|
|
});
|