diff --git a/src/server/core/domain/channel/errors.ts b/src/server/core/domain/channel/errors.ts new file mode 100644 index 000000000..66601e262 --- /dev/null +++ b/src/server/core/domain/channel/errors.ts @@ -0,0 +1,75 @@ +/** + * Canonical wire codes for channel-domain errors. + * + * The daemon transport forwards a thrown {@link ChannelError}'s `code` verbatim + * in its `{success: false, code, error}` envelope, so these strings are part of + * the client-facing contract. Trimmed to the codes the skeleton needs; more + * land with the milestones that introduce them. + */ +export const CHANNEL_ERROR_CODE = { + DISABLED: 'CHANNEL_DISABLED', + INVALID_REQUEST: 'CHANNEL_INVALID_REQUEST', + NOT_IMPLEMENTED: 'CHANNEL_NOT_IMPLEMENTED' +} as const + +/** + * Represents any channel-domain error. Carries the canonical wire `code` and + * optional structured `details` for the transport error envelope. + */ +export class ChannelError extends Error { + public readonly code: string + public readonly details?: unknown + + public constructor(message: string, code: string, details?: unknown) { + super(message) + this.name = 'ChannelError' + this.code = code + this.details = details + } +} + +/** + * Signals that a `channel:*` event is registered but has no behavior yet. The + * skeleton throws this from every handler once the payload validates, so the + * wire surface stays stable while later milestones supply behavior. + */ +export class ChannelNotImplementedError extends ChannelError { + public constructor(event?: string) { + super( + event === undefined + ? 'channel operation is not implemented yet' + : `channel operation '${event}' is not implemented yet`, + CHANNEL_ERROR_CODE.NOT_IMPLEMENTED + ) + this.name = 'ChannelNotImplementedError' + } +} + +/** + * Signals that the channel surface is administratively disabled on this host + * (`BRV_CHANNELS_ENABLED` is set to a falsy value). A stub throwing this is + * registered for every `channel:*` event so the Socket.IO ack still fires + * instead of hanging. + */ +export class ChannelDisabledError extends ChannelError { + public constructor(message?: string) { + super( + message ?? + 'channel surface is disabled on this host (BRV_CHANNELS_ENABLED is set to a falsy value)', + CHANNEL_ERROR_CODE.DISABLED, + ) + this.name = 'ChannelDisabledError' + } +} + +/** + * Signals that a request payload failed schema validation at the wire boundary. + * `details` carries the flattened zod error so clients can surface specific + * field problems. + */ +export class ChannelInvalidRequestError extends ChannelError { + public constructor(message: string, details?: unknown) { + super(message, CHANNEL_ERROR_CODE.INVALID_REQUEST, details) + this.name = 'ChannelInvalidRequestError' + } +} \ No newline at end of file diff --git a/src/server/infra/process/feature-handlers.ts b/src/server/infra/process/feature-handlers.ts index 0d3183ef0..1a59e2b7e 100644 --- a/src/server/infra/process/feature-handlers.ts +++ b/src/server/infra/process/feature-handlers.ts @@ -53,6 +53,17 @@ import {FileReviewBackupStore} from '../storage/file-review-backup-store.js' import {createTokenStore} from '../storage/token-store.js' import {HttpTeamService} from '../team/http-team-service.js' import {FsTemplateLoader} from '../template/fs-template-loader.js' +import {channelsEnabled, registerDisabledStubs} from '../transport/handlers/channel-disabled-handler.js' +import {ChannelCancelHandler} from '../transport/handlers/channel/channel-cancel-handler.js' +import {ChannelCreateHandler} from '../transport/handlers/channel/channel-create-handler.js' +import {ChannelGetHandler} from '../transport/handlers/channel/channel-get-handler.js' +import {ChannelInviteHandler} from '../transport/handlers/channel/channel-invite-handler.js' +import {ChannelListHandler} from '../transport/handlers/channel/channel-list-handler.js' +import {ChannelListTurnsHandler} from '../transport/handlers/channel/channel-list-turns-handler.js' +import {ChannelMentionHandler} from '../transport/handlers/channel/channel-mention-handler.js' +import {ChannelOnboardHandler} from '../transport/handlers/channel/channel-onboard-handler.js' +import {ChannelShowHandler} from '../transport/handlers/channel/channel-show-handler.js' +import {ChannelSubscribeHandler} from '../transport/handlers/channel/channel-subscribe-handler.js' import { AuthHandler, BillingHandler, @@ -136,6 +147,23 @@ export async function setupFeatureHandlers({ new ConfigHandler({transport}).setup() new SettingsHandler({store: settingsStore, transport}).setup() + // Channel handlers are gated behind BRV_CHANNELS_ENABLED (opt-out). When the + // surface is disabled, register stubs so `channel:*` requests still ack. + if (channelsEnabled()) { + new ChannelCreateHandler(transport).setup() + new ChannelListHandler(transport).setup() + new ChannelGetHandler(transport).setup() + new ChannelInviteHandler(transport).setup() + new ChannelOnboardHandler(transport).setup() + new ChannelMentionHandler(transport).setup() + new ChannelShowHandler(transport).setup() + new ChannelListTurnsHandler(transport).setup() + new ChannelSubscribeHandler(transport).setup() + new ChannelCancelHandler(transport).setup() + } else { + registerDisabledStubs(transport) + } + new AuthHandler({ authService: new OAuthService(authConfig), authStateStore, diff --git a/src/server/infra/transport/handlers/channel-disabled-handler.ts b/src/server/infra/transport/handlers/channel-disabled-handler.ts new file mode 100644 index 000000000..1519d02cb --- /dev/null +++ b/src/server/infra/transport/handlers/channel-disabled-handler.ts @@ -0,0 +1,44 @@ +import type {ITransportServer} from '../../../core/interfaces/transport/index.js' + +import {ChannelEvents} from '../../../../shared/transport/events/channel-events.js' +import {ChannelDisabledError} from '../../../core/domain/channel/errors.js' + +/** Every `channel:*` request event the live per-event handlers register. */ +const STUBBABLE_EVENTS = [ + ChannelEvents.CREATE, + ChannelEvents.LIST, + ChannelEvents.GET, + ChannelEvents.INVITE, + ChannelEvents.ONBOARD, + ChannelEvents.MENTION, + ChannelEvents.SHOW, + ChannelEvents.LIST_TURNS, + ChannelEvents.SUBSCRIBE, + ChannelEvents.CANCEL, +] as const + +/** + * Registers a stub for every `channel:*` request event that throws + * {@link ChannelDisabledError}. Without it, a `channel:*` request sent while the + * surface is disabled would never receive an ack and the client would hang. + * Returns the list of stubbed events. + */ +export const registerDisabledStubs = (transport: ITransportServer): readonly string[] => { + for (const event of STUBBABLE_EVENTS) { + transport.onRequest(event, () => { + throw new ChannelDisabledError() + }) + } + + return STUBBABLE_EVENTS +} + +/** + * Reports whether the channel surface is enabled. Opt-out: enabled unless + * `BRV_CHANNELS_ENABLED` is `0`, `false`, `no`, or `off` (case-insensitive). + */ +export const channelsEnabled = (env: NodeJS.ProcessEnv = process.env): boolean => { + const value = env.BRV_CHANNELS_ENABLED + if (value === undefined) return true + return !['0', 'false', 'no', 'off'].includes(value.trim().toLowerCase()) +} diff --git a/src/server/infra/transport/handlers/channel/channel-cancel-handler.ts b/src/server/infra/transport/handlers/channel/channel-cancel-handler.ts new file mode 100644 index 000000000..ef53fbdd8 --- /dev/null +++ b/src/server/infra/transport/handlers/channel/channel-cancel-handler.ts @@ -0,0 +1,39 @@ +import type {ITransportServer} from '../../../../core/interfaces/transport/index.js' +import type {ITransportHandler} from '../i-transport-handler.js' + +import { + ChannelCancelRequest, + ChannelCancelRequestSchema, + ChannelCancelResponse, + ChannelEvents, +} from '../../../../../shared/transport/events/channel-events.js' +import {ChannelNotImplementedError} from '../../../../core/domain/channel/errors.js' +import {parseOrThrow} from './parse-or-throw.js' + +/** + * Handles `channel:cancel`. `handle` validates the payload, then throws + * {@link ChannelNotImplementedError}; the cancel behavior lands in a later + * milestone, written after the validation step in `handle`. + */ +export class ChannelCancelHandler + implements ITransportHandler +{ + private readonly event: string + private readonly transportServer: ITransportServer + + public constructor(transportServer: ITransportServer) { + this.event = ChannelEvents.CANCEL + this.transportServer = transportServer + } + + public async handle(request: ChannelCancelRequest): Promise { + parseOrThrow(ChannelCancelRequestSchema, request) + throw new ChannelNotImplementedError(this.event) + } + + public setup(): void { + this.transportServer.onRequest(this.event, (request) => + this.handle(request), + ) + } +} diff --git a/src/server/infra/transport/handlers/channel/channel-create-handler.ts b/src/server/infra/transport/handlers/channel/channel-create-handler.ts new file mode 100644 index 000000000..ff63ef398 --- /dev/null +++ b/src/server/infra/transport/handlers/channel/channel-create-handler.ts @@ -0,0 +1,25 @@ +import type {ITransportServer} from "../../../../core/interfaces/transport/index.js"; +import type {ITransportHandler} from "../i-transport-handler.js"; + +import {type ChannelCreateRequest, ChannelCreateRequestSchema, type ChannelCreateResponse, ChannelEvents} from "../../../../../shared/transport/events/channel-events.js"; +import {ChannelNotImplementedError} from "../../../../core/domain/channel/errors.js"; +import {parseOrThrow} from "./parse-or-throw.js"; + +export class ChannelCreateHandler implements ITransportHandler { + private readonly event: string + private readonly transportServer: ITransportServer + + public constructor(transportServer: ITransportServer) { + this.event = ChannelEvents.CREATE + this.transportServer = transportServer + } + + public async handle(request: ChannelCreateRequest): Promise { + parseOrThrow(ChannelCreateRequestSchema, request) + throw new ChannelNotImplementedError(this.event) + } + + public setup(): void { + this.transportServer.onRequest(this.event, request => this.handle(request)) + } +} \ No newline at end of file diff --git a/src/server/infra/transport/handlers/channel/channel-get-handler.ts b/src/server/infra/transport/handlers/channel/channel-get-handler.ts new file mode 100644 index 000000000..a04eed398 --- /dev/null +++ b/src/server/infra/transport/handlers/channel/channel-get-handler.ts @@ -0,0 +1,37 @@ +import type {ITransportServer} from '../../../../core/interfaces/transport/index.js' +import type {ITransportHandler} from '../i-transport-handler.js' + +import { + ChannelEvents, + ChannelGetRequest, + ChannelGetRequestSchema, + ChannelGetResponse, +} from '../../../../../shared/transport/events/channel-events.js' +import {ChannelNotImplementedError} from '../../../../core/domain/channel/errors.js' +import {parseOrThrow} from './parse-or-throw.js' + +/** + * Handles `channel:get`. `handle` validates the payload, then throws + * {@link ChannelNotImplementedError}; the get behavior lands in a later + * milestone, written after the validation step in `handle`. + */ +export class ChannelGetHandler implements ITransportHandler { + private readonly event: string + private readonly transportServer: ITransportServer + + public constructor(transportServer: ITransportServer) { + this.event = ChannelEvents.GET + this.transportServer = transportServer + } + + public async handle(request: ChannelGetRequest): Promise { + parseOrThrow(ChannelGetRequestSchema, request) + throw new ChannelNotImplementedError(this.event) + } + + public setup(): void { + this.transportServer.onRequest(this.event, (request) => + this.handle(request), + ) + } +} diff --git a/src/server/infra/transport/handlers/channel/channel-invite-handler.ts b/src/server/infra/transport/handlers/channel/channel-invite-handler.ts new file mode 100644 index 000000000..01f864dba --- /dev/null +++ b/src/server/infra/transport/handlers/channel/channel-invite-handler.ts @@ -0,0 +1,39 @@ +import type {ITransportServer} from '../../../../core/interfaces/transport/index.js' +import type {ITransportHandler} from '../i-transport-handler.js' + +import { + ChannelEvents, + ChannelInviteRequest, + ChannelInviteRequestSchema, + ChannelInviteResponse, +} from '../../../../../shared/transport/events/channel-events.js' +import {ChannelNotImplementedError} from '../../../../core/domain/channel/errors.js' +import {parseOrThrow} from './parse-or-throw.js' + +/** + * Handles `channel:invite`. `handle` validates the payload, then throws + * {@link ChannelNotImplementedError}; the invite behavior lands in a later + * milestone, written after the validation step in `handle`. + */ +export class ChannelInviteHandler + implements ITransportHandler +{ + private readonly event: string + private readonly transportServer: ITransportServer + + public constructor(transportServer: ITransportServer) { + this.event = ChannelEvents.INVITE + this.transportServer = transportServer + } + + public async handle(request: ChannelInviteRequest): Promise { + parseOrThrow(ChannelInviteRequestSchema, request) + throw new ChannelNotImplementedError(this.event) + } + + public setup(): void { + this.transportServer.onRequest(this.event, (request) => + this.handle(request), + ) + } +} diff --git a/src/server/infra/transport/handlers/channel/channel-list-handler.ts b/src/server/infra/transport/handlers/channel/channel-list-handler.ts new file mode 100644 index 000000000..24e594e54 --- /dev/null +++ b/src/server/infra/transport/handlers/channel/channel-list-handler.ts @@ -0,0 +1,37 @@ +import type {ITransportServer} from '../../../../core/interfaces/transport/index.js' +import type {ITransportHandler} from '../i-transport-handler.js' + +import { + ChannelEvents, + ChannelListRequest, + ChannelListRequestSchema, + ChannelListResponse, +} from '../../../../../shared/transport/events/channel-events.js' +import {ChannelNotImplementedError} from '../../../../core/domain/channel/errors.js' +import {parseOrThrow} from './parse-or-throw.js' + +/** + * Handles `channel:list`. `handle` validates the payload, then throws + * {@link ChannelNotImplementedError}; the list behavior lands in a later + * milestone, written after the validation step in `handle`. + */ +export class ChannelListHandler implements ITransportHandler { + private readonly event: string + private readonly transportServer: ITransportServer + + public constructor(transportServer: ITransportServer) { + this.event = ChannelEvents.LIST + this.transportServer = transportServer + } + + public async handle(request: ChannelListRequest): Promise { + parseOrThrow(ChannelListRequestSchema, request) + throw new ChannelNotImplementedError(this.event) + } + + public setup(): void { + this.transportServer.onRequest(this.event, (request) => + this.handle(request), + ) + } +} diff --git a/src/server/infra/transport/handlers/channel/channel-list-turns-handler.ts b/src/server/infra/transport/handlers/channel/channel-list-turns-handler.ts new file mode 100644 index 000000000..6ecaa8cef --- /dev/null +++ b/src/server/infra/transport/handlers/channel/channel-list-turns-handler.ts @@ -0,0 +1,40 @@ +import type {ITransportServer} from '../../../../core/interfaces/transport/index.js' +import type {ITransportHandler} from '../i-transport-handler.js' + +import { + ChannelEvents, + ChannelListTurnsRequest, + ChannelListTurnsRequestSchema, + ChannelListTurnsResponse, +} from '../../../../../shared/transport/events/channel-events.js' +import {ChannelNotImplementedError} from '../../../../core/domain/channel/errors.js' +import {parseOrThrow} from './parse-or-throw.js' + +/** + * Handles `channel:list-turns`. `handle` validates the payload, then throws + * {@link ChannelNotImplementedError}; the list-turns behavior lands in a later + * milestone, written after the validation step in `handle`. + */ +export class ChannelListTurnsHandler + implements ITransportHandler +{ + private readonly event: string + private readonly transportServer: ITransportServer + + public constructor(transportServer: ITransportServer) { + this.event = ChannelEvents.LIST_TURNS + this.transportServer = transportServer + } + + public async handle(request: ChannelListTurnsRequest): Promise { + parseOrThrow(ChannelListTurnsRequestSchema, request) + throw new ChannelNotImplementedError(this.event) + } + + public setup(): void { + this.transportServer.onRequest( + this.event, + (request) => this.handle(request), + ) + } +} diff --git a/src/server/infra/transport/handlers/channel/channel-mention-handler.ts b/src/server/infra/transport/handlers/channel/channel-mention-handler.ts new file mode 100644 index 000000000..8823419e3 --- /dev/null +++ b/src/server/infra/transport/handlers/channel/channel-mention-handler.ts @@ -0,0 +1,39 @@ +import type {ITransportServer} from '../../../../core/interfaces/transport/index.js' +import type {ITransportHandler} from '../i-transport-handler.js' + +import { + ChannelEvents, + ChannelMentionRequest, + ChannelMentionRequestSchema, + ChannelMentionResponse, +} from '../../../../../shared/transport/events/channel-events.js' +import {ChannelNotImplementedError} from '../../../../core/domain/channel/errors.js' +import {parseOrThrow} from './parse-or-throw.js' + +/** + * Handles `channel:mention`. `handle` validates the payload, then throws + * {@link ChannelNotImplementedError}; the mention behavior lands in a later + * milestone, written after the validation step in `handle`. + */ +export class ChannelMentionHandler + implements ITransportHandler +{ + private readonly event: string + private readonly transportServer: ITransportServer + + public constructor(transportServer: ITransportServer) { + this.event = ChannelEvents.MENTION + this.transportServer = transportServer + } + + public async handle(request: ChannelMentionRequest): Promise { + parseOrThrow(ChannelMentionRequestSchema, request) + throw new ChannelNotImplementedError(this.event) + } + + public setup(): void { + this.transportServer.onRequest(this.event, (request) => + this.handle(request), + ) + } +} diff --git a/src/server/infra/transport/handlers/channel/channel-onboard-handler.ts b/src/server/infra/transport/handlers/channel/channel-onboard-handler.ts new file mode 100644 index 000000000..dd05510b8 --- /dev/null +++ b/src/server/infra/transport/handlers/channel/channel-onboard-handler.ts @@ -0,0 +1,39 @@ +import type {ITransportServer} from '../../../../core/interfaces/transport/index.js' +import type {ITransportHandler} from '../i-transport-handler.js' + +import { + ChannelEvents, + ChannelOnboardRequest, + ChannelOnboardRequestSchema, + ChannelOnboardResponse, +} from '../../../../../shared/transport/events/channel-events.js' +import {ChannelNotImplementedError} from '../../../../core/domain/channel/errors.js' +import {parseOrThrow} from './parse-or-throw.js' + +/** + * Handles `channel:onboard`. `handle` validates the payload, then throws + * {@link ChannelNotImplementedError}; the onboard behavior lands in a later + * milestone, written after the validation step in `handle`. + */ +export class ChannelOnboardHandler + implements ITransportHandler +{ + private readonly event: string + private readonly transportServer: ITransportServer + + public constructor(transportServer: ITransportServer) { + this.event = ChannelEvents.ONBOARD + this.transportServer = transportServer + } + + public async handle(request: ChannelOnboardRequest): Promise { + parseOrThrow(ChannelOnboardRequestSchema, request) + throw new ChannelNotImplementedError(this.event) + } + + public setup(): void { + this.transportServer.onRequest(this.event, (request) => + this.handle(request), + ) + } +} diff --git a/src/server/infra/transport/handlers/channel/channel-show-handler.ts b/src/server/infra/transport/handlers/channel/channel-show-handler.ts new file mode 100644 index 000000000..22b8d7f66 --- /dev/null +++ b/src/server/infra/transport/handlers/channel/channel-show-handler.ts @@ -0,0 +1,37 @@ +import type {ITransportServer} from '../../../../core/interfaces/transport/index.js' +import type {ITransportHandler} from '../i-transport-handler.js' + +import { + ChannelEvents, + ChannelShowRequest, + ChannelShowRequestSchema, + ChannelShowResponse, +} from '../../../../../shared/transport/events/channel-events.js' +import {ChannelNotImplementedError} from '../../../../core/domain/channel/errors.js' +import {parseOrThrow} from './parse-or-throw.js' + +/** + * Handles `channel:show`. `handle` validates the payload, then throws + * {@link ChannelNotImplementedError}; the show behavior lands in a later + * milestone, written after the validation step in `handle`. + */ +export class ChannelShowHandler implements ITransportHandler { + private readonly event: string + private readonly transportServer: ITransportServer + + public constructor(transportServer: ITransportServer) { + this.event = ChannelEvents.SHOW + this.transportServer = transportServer + } + + public async handle(request: ChannelShowRequest): Promise { + parseOrThrow(ChannelShowRequestSchema, request) + throw new ChannelNotImplementedError(this.event) + } + + public setup(): void { + this.transportServer.onRequest(this.event, (request) => + this.handle(request), + ) + } +} diff --git a/src/server/infra/transport/handlers/channel/channel-subscribe-handler.ts b/src/server/infra/transport/handlers/channel/channel-subscribe-handler.ts new file mode 100644 index 000000000..464f746e9 --- /dev/null +++ b/src/server/infra/transport/handlers/channel/channel-subscribe-handler.ts @@ -0,0 +1,40 @@ +import type {ITransportServer} from '../../../../core/interfaces/transport/index.js' +import type {ITransportHandler} from '../i-transport-handler.js' + +import { + ChannelEvents, + ChannelSubscribeRequest, + ChannelSubscribeRequestSchema, + ChannelSubscribeResponse, +} from '../../../../../shared/transport/events/channel-events.js' +import {ChannelNotImplementedError} from '../../../../core/domain/channel/errors.js' +import {parseOrThrow} from './parse-or-throw.js' + +/** + * Handles `channel:subscribe`. `handle` validates the payload, then throws + * {@link ChannelNotImplementedError}; the subscribe behavior lands in a later + * milestone, written after the validation step in `handle`. + */ +export class ChannelSubscribeHandler + implements ITransportHandler +{ + private readonly event: string + private readonly transportServer: ITransportServer + + public constructor(transportServer: ITransportServer) { + this.event = ChannelEvents.SUBSCRIBE + this.transportServer = transportServer + } + + public async handle(request: ChannelSubscribeRequest): Promise { + parseOrThrow(ChannelSubscribeRequestSchema, request) + throw new ChannelNotImplementedError(this.event) + } + + public setup(): void { + this.transportServer.onRequest( + this.event, + (request) => this.handle(request), + ) + } +} diff --git a/src/server/infra/transport/handlers/channel/parse-or-throw.ts b/src/server/infra/transport/handlers/channel/parse-or-throw.ts new file mode 100644 index 000000000..3f6f33f5a --- /dev/null +++ b/src/server/infra/transport/handlers/channel/parse-or-throw.ts @@ -0,0 +1,20 @@ +import type {z} from 'zod' + +import {ChannelInvalidRequestError} from '../../../../core/domain/channel/errors.js' + +/** + * Validates `data` against `schema`, throwing {@link ChannelInvalidRequestError} + * (CHANNEL_INVALID_REQUEST) with the flattened zod error as `details` when the + * payload does not conform. Shared by the per-event channel handlers. + */ +export const parseOrThrow = (schema: z.ZodType, data: unknown): T => { + const parsed = schema.safeParse(data) + if (!parsed.success) { + throw new ChannelInvalidRequestError( + 'channel request payload failed schema validation', + parsed.error.flatten(), + ) + } + + return parsed.data +} diff --git a/src/server/infra/transport/handlers/i-transport-handler.ts b/src/server/infra/transport/handlers/i-transport-handler.ts new file mode 100644 index 000000000..6ea34f118 --- /dev/null +++ b/src/server/infra/transport/handlers/i-transport-handler.ts @@ -0,0 +1,4 @@ +export interface ITransportHandler { + handle(request: Request): Promise + setup(): void +} \ No newline at end of file diff --git a/src/shared/transport/events/channel-events.ts b/src/shared/transport/events/channel-events.ts new file mode 100644 index 000000000..f00d1cdd2 --- /dev/null +++ b/src/shared/transport/events/channel-events.ts @@ -0,0 +1,212 @@ +import {z} from 'zod' + +import { + ChannelMemberSchema, + ChannelSchema, + ContentBlockSchema, + HandleSchema, + TurnEventSchema, + TurnSchema, +} from '../../types/index.js' + +/* eslint-disable perfectionist/sort-objects */ +export const ChannelEvents = { + // Lifecycle + CREATE: 'channel:create', + LIST: 'channel:list', + GET: 'channel:get', + + // Membership + INVITE: 'channel:invite', + ONBOARD: 'channel:onboard', + + // Turns + MENTION: 'channel:mention', + SHOW: 'channel:show', + LIST_TURNS: 'channel:list-turns', + SUBSCRIBE: 'channel:subscribe', + CANCEL: 'channel:cancel', + + // Broadcasts (server to client on the channel room; not registered via onRequest) + TURN_EVENT: 'channel:turn-event', + STATE_CHANGE: 'channel:state-change', + MEMBER_UPDATE: 'channel:member-update', +} as const +/* eslint-enable perfectionist/sort-objects */ + +export type ChannelEvent = (typeof ChannelEvents)[keyof typeof ChannelEvents] + +/** channel:create */ +export const ChannelCreateRequestSchema = z.object({ + channelId: z.string().optional(), + idempotencyKey: z.string().optional(), + title: z.string().optional(), +}) + +export type ChannelCreateRequest = z.infer + +export const ChannelCreateResponseSchema = z.object({ + channel: ChannelSchema +}) + +export type ChannelCreateResponse = z.infer + +/** channel:list */ +export const ChannelListRequestSchema = z.object({ + archived: z.boolean().optional(), +}) + +export type ChannelListRequest = z.infer + +export const ChannelListResponseSchema = z.object({ + channels: z.array(ChannelSchema), +}) + +export type ChannelListResponse = z.infer + +/** channel:get */ +export const ChannelGetRequestSchema = z.object({ + channelId: z.string(), +}) + +export type ChannelGetRequest = z.infer + +export const ChannelGetResponseSchema = z.object({ + channel: ChannelSchema, +}) + +export type ChannelGetResponse = z.infer + +/** + * channel:invite — `invocation` is intentionally loose at the skeleton layer; + * the launch-spec shape is tightened when the driver/profile machinery lands. + */ +export const ChannelInviteRequestSchema = z.object({ + channelId: z.string(), + handle: HandleSchema, + invocation: z.unknown().optional(), + profileName: z.string().optional(), +}) + +export type ChannelInviteRequest = z.infer + +export const ChannelInviteResponseSchema = z.object({ + member: ChannelMemberSchema, +}) + +export type ChannelInviteResponse = z.infer + +/** channel:onboard */ +export const ChannelOnboardRequestSchema = z.object({ + channelId: z.string(), + handle: HandleSchema, + invocation: z.unknown().optional(), +}) + +export type ChannelOnboardRequest = z.infer + +export const ChannelOnboardResponseSchema = z.object({ + member: ChannelMemberSchema, +}) + +export type ChannelOnboardResponse = z.infer + +/** channel:mention — carries the M0-1 ContentBlock/Handle shapes unchanged. */ +export const ChannelMentionRequestSchema = z.object({ + channelId: z.string(), + idempotencyKey: z.string().optional(), + mentions: z.array(HandleSchema).optional(), + prompt: z.string().optional(), + promptBlocks: z.array(ContentBlockSchema).optional(), +}) + +export type ChannelMentionRequest = z.infer + +export const ChannelMentionResponseSchema = z.object({ + turn: TurnSchema, +}) + +export type ChannelMentionResponse = z.infer + +/** channel:show — carries the M0-1 Turn/TurnEvent shapes unchanged. */ +export const ChannelShowRequestSchema = z.object({ + channelId: z.string(), + turnId: z.string(), +}) + +export type ChannelShowRequest = z.infer + +export const ChannelShowResponseSchema = z.object({ + events: z.array(TurnEventSchema), + turn: TurnSchema, +}) + +export type ChannelShowResponse = z.infer + +/** channel:list-turns */ +export const ChannelListTurnsRequestSchema = z.object({ + channelId: z.string(), + cursor: z.string().optional(), + limit: z.number().int().positive().optional(), +}) + +export type ChannelListTurnsRequest = z.infer + +export const ChannelListTurnsResponseSchema = z.object({ + nextCursor: z.string().optional(), + turns: z.array(TurnSchema), +}) + +export type ChannelListTurnsResponse = z.infer + +/** channel:subscribe */ +export const ChannelSubscribeRequestSchema = z.object({ + channelId: z.string(), +}) + +export type ChannelSubscribeRequest = z.infer + +export const ChannelSubscribeResponseSchema = z.object({ + channelId: z.string(), + subscribed: z.literal(true), +}) + +export type ChannelSubscribeResponse = z.infer + +/** channel:cancel */ +export const ChannelCancelRequestSchema = z.object({ + channelId: z.string(), + turnId: z.string(), +}) + +export type ChannelCancelRequest = z.infer + +export const ChannelCancelResponseSchema = z.object({ + cancelled: z.boolean(), +}) + +export type ChannelCancelResponse = z.infer + +// ─── Broadcast payloads (server → client on the channel room) ──────────────── + +export const ChannelTurnEventBroadcastSchema = z.object({ + channelId: z.string(), + event: TurnEventSchema, +}) + +export type ChannelTurnEventBroadcast = z.infer + +export const ChannelStateChangeBroadcastSchema = z.object({ + channel: ChannelSchema, + channelId: z.string(), +}) + +export type ChannelStateChangeBroadcast = z.infer + +export const ChannelMemberUpdateBroadcastSchema = z.object({ + channelId: z.string(), + member: ChannelMemberSchema, + op: z.enum(['added', 'removed', 'updated']), +}) + +export type ChannelMemberUpdateBroadcast = z.infer \ No newline at end of file diff --git a/src/shared/transport/events/index.ts b/src/shared/transport/events/index.ts index 8abba92ae..c2370bbe6 100644 --- a/src/shared/transport/events/index.ts +++ b/src/shared/transport/events/index.ts @@ -5,6 +5,7 @@ export * from '../types/dto.js' export * from './agent-events.js' export * from './auth-events.js' export * from './billing-events.js' +export * from './channel-events.js' export * from './client-events.js' export * from './config-events.js' export * from './connector-events.js' diff --git a/test/unit/infra/transport/handlers/channel-disabled-handler.test.ts b/test/unit/infra/transport/handlers/channel-disabled-handler.test.ts new file mode 100644 index 000000000..9695dd809 --- /dev/null +++ b/test/unit/infra/transport/handlers/channel-disabled-handler.test.ts @@ -0,0 +1,95 @@ +import {expect} from 'chai' + +import {ChannelDisabledError} from '../../../../../src/server/core/domain/channel/errors.js' +import { + channelsEnabled, + registerDisabledStubs, +} from '../../../../../src/server/infra/transport/handlers/channel-disabled-handler.js' +import {ChannelEvents} from '../../../../../src/shared/transport/events/channel-events.js' +import {createMockTransportServer} from '../../../../helpers/mock-factories.js' + +const REQUEST_EVENTS = [ + ChannelEvents.CREATE, + ChannelEvents.LIST, + ChannelEvents.GET, + ChannelEvents.INVITE, + ChannelEvents.ONBOARD, + ChannelEvents.MENTION, + ChannelEvents.SHOW, + ChannelEvents.LIST_TURNS, + ChannelEvents.SUBSCRIBE, + ChannelEvents.CANCEL, +] as const + +const BROADCAST_EVENTS = [ + ChannelEvents.TURN_EVENT, + ChannelEvents.STATE_CHANGE, + ChannelEvents.MEMBER_UPDATE, +] as const + +describe('channel-disabled-handler', () => { + describe('registerDisabledStubs', () => { + it('registers a stub for every channel request event', () => { + const transport = createMockTransportServer() + + const registered = registerDisabledStubs(transport) + + for (const event of REQUEST_EVENTS) { + expect(transport._handlers.has(event), `expected ${event} to be stubbed`).to.equal(true) + } + + expect(registered.length).to.equal(REQUEST_EVENTS.length) + }) + + it('does not stub broadcast events', () => { + const transport = createMockTransportServer() + + registerDisabledStubs(transport) + + for (const event of BROADCAST_EVENTS) { + expect(transport._handlers.has(event), `${event} must not be stubbed`).to.equal(false) + } + }) + + it('every stub rejects with CHANNEL_DISABLED', async () => { + const transport = createMockTransportServer() + registerDisabledStubs(transport) + + for (const event of REQUEST_EVENTS) { + const handler = transport._handlers.get(event) + if (handler === undefined) throw new Error(`stub for ${event} was not registered`) + + let thrown: unknown + try { + // eslint-disable-next-line no-await-in-loop + await handler({}, 'client-1') + } catch (error) { + thrown = error + } + + expect(thrown, event).to.be.instanceOf(ChannelDisabledError) + if (thrown instanceof ChannelDisabledError) { + expect(thrown.code, event).to.equal('CHANNEL_DISABLED') + } + } + }) + }) + + describe('channelsEnabled', () => { + it('is enabled by default when the env var is unset (opt-out)', () => { + expect(channelsEnabled({})).to.equal(true) + }) + + it('is disabled for 0/false/no/off (case- and whitespace-insensitive)', () => { + for (const value of ['0', 'false', 'FALSE', 'no', 'off', ' Off ']) { + expect(channelsEnabled({BRV_CHANNELS_ENABLED: value}), value).to.equal(false) + } + }) + + it('is enabled for any other value', () => { + for (const value of ['1', 'true', 'yes', 'on', 'anything']) { + expect(channelsEnabled({BRV_CHANNELS_ENABLED: value}), value).to.equal(true) + } + }) + }) +}) diff --git a/test/unit/infra/transport/handlers/channel/channel-cancel-handler.test.ts b/test/unit/infra/transport/handlers/channel/channel-cancel-handler.test.ts new file mode 100644 index 000000000..202903ca4 --- /dev/null +++ b/test/unit/infra/transport/handlers/channel/channel-cancel-handler.test.ts @@ -0,0 +1,46 @@ +import {expect} from 'chai' + +import { + ChannelInvalidRequestError, + ChannelNotImplementedError, +} from '../../../../../../src/server/core/domain/channel/errors.js' +import {ChannelCancelHandler} from '../../../../../../src/server/infra/transport/handlers/channel/channel-cancel-handler.js' +import {ChannelEvents} from '../../../../../../src/shared/transport/events/channel-events.js' +import {createMockTransportServer} from '../../../../../helpers/mock-factories.js' + +describe('ChannelCancelHandler', () => { + let transport: ReturnType + + beforeEach(() => { + transport = createMockTransportServer() + new ChannelCancelHandler(transport).setup() + }) + + async function invoke(payload: unknown): Promise { + const handler = transport._handlers.get(ChannelEvents.CANCEL) + if (handler === undefined) throw new Error('channel:cancel was not registered') + try { + await handler(payload, 'client-1') + } catch (error) { + return error + } + + throw new Error('channel:cancel handler resolved but was expected to throw') + } + + it('registers channel:cancel on setup', () => { + expect(transport._handlers.has(ChannelEvents.CANCEL)).to.equal(true) + }) + + it('throws CHANNEL_NOT_IMPLEMENTED for a valid payload', async () => { + const error = await invoke({channelId: 'c1', turnId: 't1'}) + + expect(error).to.be.instanceOf(ChannelNotImplementedError) + }) + + it('throws CHANNEL_INVALID_REQUEST when turnId is missing', async () => { + const error = await invoke({channelId: 'c1'}) + + expect(error).to.be.instanceOf(ChannelInvalidRequestError) + }) +}) diff --git a/test/unit/infra/transport/handlers/channel/channel-create-handler.test.ts b/test/unit/infra/transport/handlers/channel/channel-create-handler.test.ts new file mode 100644 index 000000000..bf6179b74 --- /dev/null +++ b/test/unit/infra/transport/handlers/channel/channel-create-handler.test.ts @@ -0,0 +1,46 @@ +import {expect} from 'chai' + +import { + ChannelInvalidRequestError, + ChannelNotImplementedError, +} from '../../../../../../src/server/core/domain/channel/errors.js' +import {ChannelCreateHandler} from '../../../../../../src/server/infra/transport/handlers/channel/channel-create-handler.js' +import {ChannelEvents} from '../../../../../../src/shared/transport/events/channel-events.js' +import {createMockTransportServer} from '../../../../../helpers/mock-factories.js' + +describe('ChannelCreateHandler', () => { + let transport: ReturnType + + beforeEach(() => { + transport = createMockTransportServer() + new ChannelCreateHandler(transport).setup() + }) + + async function invoke(payload: unknown): Promise { + const handler = transport._handlers.get(ChannelEvents.CREATE) + if (handler === undefined) throw new Error('channel:create was not registered') + try { + await handler(payload, 'client-1') + } catch (error) { + return error + } + + throw new Error('channel:create handler resolved but was expected to throw') + } + + it('registers channel:create on setup', () => { + expect(transport._handlers.has(ChannelEvents.CREATE)).to.equal(true) + }) + + it('throws CHANNEL_NOT_IMPLEMENTED for a valid payload', async () => { + const error = await invoke({}) + + expect(error).to.be.instanceOf(ChannelNotImplementedError) + }) + + it('throws CHANNEL_INVALID_REQUEST for a malformed payload', async () => { + const error = await invoke({title: 42}) + + expect(error).to.be.instanceOf(ChannelInvalidRequestError) + }) +}) diff --git a/test/unit/infra/transport/handlers/channel/channel-get-handler.test.ts b/test/unit/infra/transport/handlers/channel/channel-get-handler.test.ts new file mode 100644 index 000000000..a5bdaed57 --- /dev/null +++ b/test/unit/infra/transport/handlers/channel/channel-get-handler.test.ts @@ -0,0 +1,46 @@ +import {expect} from 'chai' + +import { + ChannelInvalidRequestError, + ChannelNotImplementedError, +} from '../../../../../../src/server/core/domain/channel/errors.js' +import {ChannelGetHandler} from '../../../../../../src/server/infra/transport/handlers/channel/channel-get-handler.js' +import {ChannelEvents} from '../../../../../../src/shared/transport/events/channel-events.js' +import {createMockTransportServer} from '../../../../../helpers/mock-factories.js' + +describe('ChannelGetHandler', () => { + let transport: ReturnType + + beforeEach(() => { + transport = createMockTransportServer() + new ChannelGetHandler(transport).setup() + }) + + async function invoke(payload: unknown): Promise { + const handler = transport._handlers.get(ChannelEvents.GET) + if (handler === undefined) throw new Error('channel:get was not registered') + try { + await handler(payload, 'client-1') + } catch (error) { + return error + } + + throw new Error('channel:get handler resolved but was expected to throw') + } + + it('registers channel:get on setup', () => { + expect(transport._handlers.has(ChannelEvents.GET)).to.equal(true) + }) + + it('throws CHANNEL_NOT_IMPLEMENTED for a valid payload', async () => { + const error = await invoke({channelId: 'c1'}) + + expect(error).to.be.instanceOf(ChannelNotImplementedError) + }) + + it('throws CHANNEL_INVALID_REQUEST for a malformed payload', async () => { + const error = await invoke({channelId: 42}) + + expect(error).to.be.instanceOf(ChannelInvalidRequestError) + }) +}) diff --git a/test/unit/infra/transport/handlers/channel/channel-invite-handler.test.ts b/test/unit/infra/transport/handlers/channel/channel-invite-handler.test.ts new file mode 100644 index 000000000..7c9216b85 --- /dev/null +++ b/test/unit/infra/transport/handlers/channel/channel-invite-handler.test.ts @@ -0,0 +1,47 @@ +import {expect} from 'chai' + +import { + ChannelInvalidRequestError, + ChannelNotImplementedError, +} from '../../../../../../src/server/core/domain/channel/errors.js' +import {ChannelInviteHandler} from '../../../../../../src/server/infra/transport/handlers/channel/channel-invite-handler.js' +import {ChannelEvents} from '../../../../../../src/shared/transport/events/channel-events.js' +import {createMockTransportServer} from '../../../../../helpers/mock-factories.js' + +describe('ChannelInviteHandler', () => { + let transport: ReturnType + + beforeEach(() => { + transport = createMockTransportServer() + new ChannelInviteHandler(transport).setup() + }) + + async function invoke(payload: unknown): Promise { + const handler = transport._handlers.get(ChannelEvents.INVITE) + if (handler === undefined) throw new Error('channel:invite was not registered') + try { + await handler(payload, 'client-1') + } catch (error) { + return error + } + + throw new Error('channel:invite handler resolved but was expected to throw') + } + + it('registers channel:invite on setup', () => { + expect(transport._handlers.has(ChannelEvents.INVITE)).to.equal(true) + }) + + it('throws CHANNEL_NOT_IMPLEMENTED for a valid payload', async () => { + const error = await invoke({channelId: 'c1', handle: '@bob'}) + + expect(error).to.be.instanceOf(ChannelNotImplementedError) + }) + + it('throws CHANNEL_INVALID_REQUEST for a malformed handle', async () => { + // handles must start with "@" + const error = await invoke({channelId: 'c1', handle: 'bob'}) + + expect(error).to.be.instanceOf(ChannelInvalidRequestError) + }) +}) diff --git a/test/unit/infra/transport/handlers/channel/channel-list-handler.test.ts b/test/unit/infra/transport/handlers/channel/channel-list-handler.test.ts new file mode 100644 index 000000000..28719f714 --- /dev/null +++ b/test/unit/infra/transport/handlers/channel/channel-list-handler.test.ts @@ -0,0 +1,46 @@ +import {expect} from 'chai' + +import { + ChannelInvalidRequestError, + ChannelNotImplementedError, +} from '../../../../../../src/server/core/domain/channel/errors.js' +import {ChannelListHandler} from '../../../../../../src/server/infra/transport/handlers/channel/channel-list-handler.js' +import {ChannelEvents} from '../../../../../../src/shared/transport/events/channel-events.js' +import {createMockTransportServer} from '../../../../../helpers/mock-factories.js' + +describe('ChannelListHandler', () => { + let transport: ReturnType + + beforeEach(() => { + transport = createMockTransportServer() + new ChannelListHandler(transport).setup() + }) + + async function invoke(payload: unknown): Promise { + const handler = transport._handlers.get(ChannelEvents.LIST) + if (handler === undefined) throw new Error('channel:list was not registered') + try { + await handler(payload, 'client-1') + } catch (error) { + return error + } + + throw new Error('channel:list handler resolved but was expected to throw') + } + + it('registers channel:list on setup', () => { + expect(transport._handlers.has(ChannelEvents.LIST)).to.equal(true) + }) + + it('throws CHANNEL_NOT_IMPLEMENTED for a valid payload', async () => { + const error = await invoke({}) + + expect(error).to.be.instanceOf(ChannelNotImplementedError) + }) + + it('throws CHANNEL_INVALID_REQUEST for a malformed payload', async () => { + const error = await invoke({archived: 'yes'}) + + expect(error).to.be.instanceOf(ChannelInvalidRequestError) + }) +}) diff --git a/test/unit/infra/transport/handlers/channel/channel-list-turns-handler.test.ts b/test/unit/infra/transport/handlers/channel/channel-list-turns-handler.test.ts new file mode 100644 index 000000000..8a58e4156 --- /dev/null +++ b/test/unit/infra/transport/handlers/channel/channel-list-turns-handler.test.ts @@ -0,0 +1,46 @@ +import {expect} from 'chai' + +import { + ChannelInvalidRequestError, + ChannelNotImplementedError, +} from '../../../../../../src/server/core/domain/channel/errors.js' +import {ChannelListTurnsHandler} from '../../../../../../src/server/infra/transport/handlers/channel/channel-list-turns-handler.js' +import {ChannelEvents} from '../../../../../../src/shared/transport/events/channel-events.js' +import {createMockTransportServer} from '../../../../../helpers/mock-factories.js' + +describe('ChannelListTurnsHandler', () => { + let transport: ReturnType + + beforeEach(() => { + transport = createMockTransportServer() + new ChannelListTurnsHandler(transport).setup() + }) + + async function invoke(payload: unknown): Promise { + const handler = transport._handlers.get(ChannelEvents.LIST_TURNS) + if (handler === undefined) throw new Error('channel:list-turns was not registered') + try { + await handler(payload, 'client-1') + } catch (error) { + return error + } + + throw new Error('channel:list-turns handler resolved but was expected to throw') + } + + it('registers channel:list-turns on setup', () => { + expect(transport._handlers.has(ChannelEvents.LIST_TURNS)).to.equal(true) + }) + + it('throws CHANNEL_NOT_IMPLEMENTED for a valid payload', async () => { + const error = await invoke({channelId: 'c1'}) + + expect(error).to.be.instanceOf(ChannelNotImplementedError) + }) + + it('throws CHANNEL_INVALID_REQUEST for a non-positive limit', async () => { + const error = await invoke({channelId: 'c1', limit: 0}) + + expect(error).to.be.instanceOf(ChannelInvalidRequestError) + }) +}) diff --git a/test/unit/infra/transport/handlers/channel/channel-mention-handler.test.ts b/test/unit/infra/transport/handlers/channel/channel-mention-handler.test.ts new file mode 100644 index 000000000..c4669cf95 --- /dev/null +++ b/test/unit/infra/transport/handlers/channel/channel-mention-handler.test.ts @@ -0,0 +1,46 @@ +import {expect} from 'chai' + +import { + ChannelInvalidRequestError, + ChannelNotImplementedError, +} from '../../../../../../src/server/core/domain/channel/errors.js' +import {ChannelMentionHandler} from '../../../../../../src/server/infra/transport/handlers/channel/channel-mention-handler.js' +import {ChannelEvents} from '../../../../../../src/shared/transport/events/channel-events.js' +import {createMockTransportServer} from '../../../../../helpers/mock-factories.js' + +describe('ChannelMentionHandler', () => { + let transport: ReturnType + + beforeEach(() => { + transport = createMockTransportServer() + new ChannelMentionHandler(transport).setup() + }) + + async function invoke(payload: unknown): Promise { + const handler = transport._handlers.get(ChannelEvents.MENTION) + if (handler === undefined) throw new Error('channel:mention was not registered') + try { + await handler(payload, 'client-1') + } catch (error) { + return error + } + + throw new Error('channel:mention handler resolved but was expected to throw') + } + + it('registers channel:mention on setup', () => { + expect(transport._handlers.has(ChannelEvents.MENTION)).to.equal(true) + }) + + it('throws CHANNEL_NOT_IMPLEMENTED for a valid payload', async () => { + const error = await invoke({channelId: 'c1', prompt: 'hi @bob'}) + + expect(error).to.be.instanceOf(ChannelNotImplementedError) + }) + + it('throws CHANNEL_INVALID_REQUEST when channelId is missing', async () => { + const error = await invoke({prompt: 'hi @bob'}) + + expect(error).to.be.instanceOf(ChannelInvalidRequestError) + }) +}) diff --git a/test/unit/infra/transport/handlers/channel/channel-onboard-handler.test.ts b/test/unit/infra/transport/handlers/channel/channel-onboard-handler.test.ts new file mode 100644 index 000000000..a0c580db8 --- /dev/null +++ b/test/unit/infra/transport/handlers/channel/channel-onboard-handler.test.ts @@ -0,0 +1,46 @@ +import {expect} from 'chai' + +import { + ChannelInvalidRequestError, + ChannelNotImplementedError, +} from '../../../../../../src/server/core/domain/channel/errors.js' +import {ChannelOnboardHandler} from '../../../../../../src/server/infra/transport/handlers/channel/channel-onboard-handler.js' +import {ChannelEvents} from '../../../../../../src/shared/transport/events/channel-events.js' +import {createMockTransportServer} from '../../../../../helpers/mock-factories.js' + +describe('ChannelOnboardHandler', () => { + let transport: ReturnType + + beforeEach(() => { + transport = createMockTransportServer() + new ChannelOnboardHandler(transport).setup() + }) + + async function invoke(payload: unknown): Promise { + const handler = transport._handlers.get(ChannelEvents.ONBOARD) + if (handler === undefined) throw new Error('channel:onboard was not registered') + try { + await handler(payload, 'client-1') + } catch (error) { + return error + } + + throw new Error('channel:onboard handler resolved but was expected to throw') + } + + it('registers channel:onboard on setup', () => { + expect(transport._handlers.has(ChannelEvents.ONBOARD)).to.equal(true) + }) + + it('throws CHANNEL_NOT_IMPLEMENTED for a valid payload', async () => { + const error = await invoke({channelId: 'c1', handle: '@bob'}) + + expect(error).to.be.instanceOf(ChannelNotImplementedError) + }) + + it('throws CHANNEL_INVALID_REQUEST when the handle is missing', async () => { + const error = await invoke({channelId: 'c1'}) + + expect(error).to.be.instanceOf(ChannelInvalidRequestError) + }) +}) diff --git a/test/unit/infra/transport/handlers/channel/channel-show-handler.test.ts b/test/unit/infra/transport/handlers/channel/channel-show-handler.test.ts new file mode 100644 index 000000000..6381daf0d --- /dev/null +++ b/test/unit/infra/transport/handlers/channel/channel-show-handler.test.ts @@ -0,0 +1,46 @@ +import {expect} from 'chai' + +import { + ChannelInvalidRequestError, + ChannelNotImplementedError, +} from '../../../../../../src/server/core/domain/channel/errors.js' +import {ChannelShowHandler} from '../../../../../../src/server/infra/transport/handlers/channel/channel-show-handler.js' +import {ChannelEvents} from '../../../../../../src/shared/transport/events/channel-events.js' +import {createMockTransportServer} from '../../../../../helpers/mock-factories.js' + +describe('ChannelShowHandler', () => { + let transport: ReturnType + + beforeEach(() => { + transport = createMockTransportServer() + new ChannelShowHandler(transport).setup() + }) + + async function invoke(payload: unknown): Promise { + const handler = transport._handlers.get(ChannelEvents.SHOW) + if (handler === undefined) throw new Error('channel:show was not registered') + try { + await handler(payload, 'client-1') + } catch (error) { + return error + } + + throw new Error('channel:show handler resolved but was expected to throw') + } + + it('registers channel:show on setup', () => { + expect(transport._handlers.has(ChannelEvents.SHOW)).to.equal(true) + }) + + it('throws CHANNEL_NOT_IMPLEMENTED for a valid payload', async () => { + const error = await invoke({channelId: 'c1', turnId: 't1'}) + + expect(error).to.be.instanceOf(ChannelNotImplementedError) + }) + + it('throws CHANNEL_INVALID_REQUEST when turnId is missing', async () => { + const error = await invoke({channelId: 'c1'}) + + expect(error).to.be.instanceOf(ChannelInvalidRequestError) + }) +}) diff --git a/test/unit/infra/transport/handlers/channel/channel-subscribe-handler.test.ts b/test/unit/infra/transport/handlers/channel/channel-subscribe-handler.test.ts new file mode 100644 index 000000000..6e3caeaaf --- /dev/null +++ b/test/unit/infra/transport/handlers/channel/channel-subscribe-handler.test.ts @@ -0,0 +1,46 @@ +import {expect} from 'chai' + +import { + ChannelInvalidRequestError, + ChannelNotImplementedError, +} from '../../../../../../src/server/core/domain/channel/errors.js' +import {ChannelSubscribeHandler} from '../../../../../../src/server/infra/transport/handlers/channel/channel-subscribe-handler.js' +import {ChannelEvents} from '../../../../../../src/shared/transport/events/channel-events.js' +import {createMockTransportServer} from '../../../../../helpers/mock-factories.js' + +describe('ChannelSubscribeHandler', () => { + let transport: ReturnType + + beforeEach(() => { + transport = createMockTransportServer() + new ChannelSubscribeHandler(transport).setup() + }) + + async function invoke(payload: unknown): Promise { + const handler = transport._handlers.get(ChannelEvents.SUBSCRIBE) + if (handler === undefined) throw new Error('channel:subscribe was not registered') + try { + await handler(payload, 'client-1') + } catch (error) { + return error + } + + throw new Error('channel:subscribe handler resolved but was expected to throw') + } + + it('registers channel:subscribe on setup', () => { + expect(transport._handlers.has(ChannelEvents.SUBSCRIBE)).to.equal(true) + }) + + it('throws CHANNEL_NOT_IMPLEMENTED for a valid payload', async () => { + const error = await invoke({channelId: 'c1'}) + + expect(error).to.be.instanceOf(ChannelNotImplementedError) + }) + + it('throws CHANNEL_INVALID_REQUEST when channelId is missing', async () => { + const error = await invoke({}) + + expect(error).to.be.instanceOf(ChannelInvalidRequestError) + }) +})