diff --git a/packages/iios-service/src/messaging/message.gateway.ts b/packages/iios-service/src/messaging/message.gateway.ts index 2967640..9501443 100644 --- a/packages/iios-service/src/messaging/message.gateway.ts +++ b/packages/iios-service/src/messaging/message.gateway.ts @@ -66,11 +66,20 @@ export class MessageGateway implements OnGatewayInit, OnGatewayConnection { } @SubscribeMessage('open_thread') - async openThread(@ConnectedSocket() client: Socket, @MessageBody() body: { threadId?: string; membership?: string; creatorRole?: string }) { + async openThread(@ConnectedSocket() client: Socket, @MessageBody() body: { threadId?: string; membership?: string; creatorRole?: string; subject?: string }) { const { principal } = client.data as SocketState; - const result = await this.messages.openThread(body?.threadId ?? null, principal, { membership: body?.membership, creatorRole: body?.creatorRole }); - await client.join(result.threadId); - return result; + try { + const result = await this.messages.openThread(body?.threadId ?? null, principal, { + membership: body?.membership, + creatorRole: body?.creatorRole, + subject: body?.subject, + }); + await client.join(result.threadId); + return result; + } catch (err) { + // Fail to an ACK'd error instead of throwing (which never acks → client hangs on "loading"). + return { error: (err as Error).message ?? 'could not open the conversation' }; + } } @SubscribeMessage('add_participant') diff --git a/packages/iios-service/src/messaging/message.service.ts b/packages/iios-service/src/messaging/message.service.ts index 4dfa4af..33b4278 100644 --- a/packages/iios-service/src/messaging/message.service.ts +++ b/packages/iios-service/src/messaging/message.service.ts @@ -59,17 +59,18 @@ export class MessageService { * for a group. Opening an existing thread is a GOVERNED join: only an existing member may * re-open it (policy `iios.thread.join`) — new members enter via addParticipant. */ - async openThread(threadId: string | null, principal: MessagePrincipal, opts?: { membership?: string; creatorRole?: string }): Promise { + async openThread(threadId: string | null, principal: MessagePrincipal, opts?: { membership?: string; creatorRole?: string; subject?: string }): Promise { if (!threadId) { await decideOrThrow(this.ports, { action: 'iios.thread.create', scope: principal }); const scope = await this.actors.resolveScope(principal); const actor = await this.actors.resolveActor(scope.id, principal); - // `membership` + `creatorRole` are generic, app-supplied thread attributes — the kernel - // stores/echoes them but never branches on their chat meaning (that lives in policy + app). + // `membership`/`creatorRole`/`subject` are generic, app-supplied thread attributes — the + // kernel stores/echoes them but never branches on their chat meaning (that lives in policy + app). const thread = await this.prisma.iiosThread.create({ data: { scopeId: scope.id, createdByActorId: actor.id, + subject: opts?.subject?.trim() || undefined, metadata: opts?.membership ? ({ membership: opts.membership } as Prisma.InputJsonValue) : undefined, }, }); diff --git a/packages/iios-service/src/threads/threads.controller.ts b/packages/iios-service/src/threads/threads.controller.ts index 3e4c95e..02e5e0b 100644 --- a/packages/iios-service/src/threads/threads.controller.ts +++ b/packages/iios-service/src/threads/threads.controller.ts @@ -28,11 +28,11 @@ export class ThreadsController { return this.messages.listThreads(this.principal(auth)); } - /** Create a thread; `membership`/`creatorRole` are opaque, app-supplied thread attributes the kernel stores but never interprets. */ + /** Create a thread; `membership`/`creatorRole`/`subject` are opaque, app-supplied attributes the kernel stores but never interprets. */ @Post() @HttpCode(201) - async createThread(@Body() body: { membership?: string; creatorRole?: string }, @Headers('authorization') auth?: string) { - return this.messages.openThread(null, this.principal(auth), { membership: body?.membership, creatorRole: body?.creatorRole }); + async createThread(@Body() body: { membership?: string; creatorRole?: string; subject?: string }, @Headers('authorization') auth?: string) { + return this.messages.openThread(null, this.principal(auth), { membership: body?.membership, creatorRole: body?.creatorRole, subject: body?.subject }); } /** Governed membership: add a user (by userId) to a thread — policy enforces DM cap / roles. */ diff --git a/scripts/vitest-global-setup.mjs b/scripts/vitest-global-setup.mjs new file mode 100644 index 0000000..6d8dfdd --- /dev/null +++ b/scripts/vitest-global-setup.mjs @@ -0,0 +1,20 @@ +import { execSync } from 'node:child_process'; + +// Tests TRUNCATE between cases, so they MUST NOT touch the dev database. Run them +// against an isolated `iios_test` DB (created + migrated here, once, before the suite). +const TEST_URL = 'postgresql://iios:iios@localhost:5434/iios_test?schema=public'; + +export default function setup() { + try { + execSync(`docker exec iios-db psql -U iios -d postgres -c "CREATE DATABASE iios_test"`, { stdio: 'pipe' }); + } catch (e) { + const msg = String(e.stderr ?? e.stdout ?? e); + if (!/already exists/i.test(msg)) { + console.warn(`[vitest] could not create iios_test (is the iios-db container up?): ${msg.slice(0, 160)}`); + } + } + execSync('pnpm --filter @insignia/iios-service exec prisma migrate deploy', { + stdio: 'inherit', + env: { ...process.env, DATABASE_URL: TEST_URL }, + }); +} diff --git a/vitest.config.ts b/vitest.config.ts index 2245582..995eb6f 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -15,9 +15,13 @@ export default defineConfig({ }, test: { include: ['packages/**/src/**/*.{test,spec}.ts', 'test/**/*.{test,spec}.ts'], - // DB-backed specs share one Postgres and TRUNCATE between tests. Run every - // file in a single worker process, sequentially, so there is no cross-file - // race on the shared database. + // DB-backed specs TRUNCATE between tests, so they run against an ISOLATED + // `iios_test` database (created + migrated by the global setup) — never the dev + // DB. This env overrides any DATABASE_URL from the shell. + env: { DATABASE_URL: 'postgresql://iios:iios@localhost:5434/iios_test?schema=public' }, + globalSetup: ['./scripts/vitest-global-setup.mjs'], + // Run every file in a single worker process, sequentially, so there is no + // cross-file race on the shared database. fileParallelism: false, sequence: { concurrent: false }, pool: 'forks',