feat(service): P3.3 inbox API + state machine (list/transition, fail-closed, STALE)

InboxService.list (fail-closed) + transition (OPEN/SNOOZED/DONE/STALE/ARCHIVED
state machine, owner-only, state-history). GET /v1/inbox/items + PATCH
/v1/inbox/items/:id. Moved SessionVerifier to global IdentityModule (shared by
message + inbox, no circular imports). 35 tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-01 03:54:11 +05:30
parent a2b95afaf0
commit 20c8f15dff
7 changed files with 156 additions and 8 deletions
+41 -1
View File
@@ -2,11 +2,12 @@ import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
import { PrismaClient } from '@prisma/client';
import { resetDb } from '../test-utils/reset-db';
import { makeFakePorts } from '@insignia/iios-testkit';
import { IIOS_EVENTS, type CloudEvent } from '@insignia/iios-contracts';
import { IIOS_EVENTS, PolicyDeniedError, type CloudEvent } from '@insignia/iios-contracts';
import { MessageService, type MessagePrincipal } from '../messaging/message.service';
import { ActorResolver } from '../identity/actor.resolver';
import { OutboxBus } from '../outbox/outbox.bus';
import { InboxProjector } from './inbox.projector';
import { InboxService } from './inbox.service';
import type { PrismaService } from '../prisma/prisma.service';
const url = process.env.DATABASE_URL ?? 'postgresql://iios:iios@localhost:5434/iios?schema=public';
@@ -103,3 +104,42 @@ describe('InboxProjector (P3 work surface)', () => {
expect(history.length).toBeGreaterThanOrEqual(1);
});
});
describe('InboxService (state machine + list)', () => {
const inbox = (ports = makeFakePorts()) => new InboxService(asService, ports, actors);
async function makeItemForBob(): Promise<string> {
const { threadId } = await ms().openThread(null, bob);
const scope = await prisma.iiosScope.findFirstOrThrow();
const item = await prisma.iiosInboxItem.create({
data: { scopeId: scope.id, ownerActorId: await actorIdFor('bob'), kind: 'NEEDS_REPLY', title: 't', threadId },
});
return item.id;
}
it('snooze / done / reopen / stale transitions land correctly and write history', async () => {
const svc = inbox();
const id = await makeItemForBob();
await svc.transition(id, bob, 'SNOOZED');
await svc.transition(id, bob, 'DONE');
await svc.transition(id, bob, 'OPEN'); // reopen
await svc.transition(id, bob, 'STALE');
expect((await prisma.iiosInboxItem.findUniqueOrThrow({ where: { id } })).state).toBe('STALE');
expect((await prisma.iiosInboxItemStateHistory.findMany({ where: { inboxItemId: id } })).length).toBe(4);
});
it('rejects an illegal transition', async () => {
const svc = inbox();
const id = await makeItemForBob();
await svc.transition(id, bob, 'ARCHIVED');
await expect(svc.transition(id, bob, 'OPEN')).rejects.toThrow(); // ARCHIVED has no out-transitions
});
it('list returns the owner OPEN items and is fail-closed', async () => {
await makeItemForBob();
expect((await inbox().list(bob, { state: 'OPEN' })).length).toBe(1);
const denied = makeFakePorts();
denied.opa.deny();
await expect(inbox(denied).list(bob, {})).rejects.toBeInstanceOf(PolicyDeniedError);
});
});