// DELETABLE. Exists only so Lane B can build before deploys. Task 12 removes it. // Shapes come from videoContract.ts (transcribed from the server design doc §3) - NOT from what is // convenient here. // // WHY THIS STUB COUNTS PARTICIPANTS. The `endCall endSession` defect (Lane A §3.3) survived a green // suite because the old stub's `endSession` just flipped a flag: nothing here could express "someone // else is still in the room", so nothing could fail when a leaving patient ended their doctor's // meeting. A stub that cannot express a distinction cannot protect it. The count is the minimum model // that makes `leave` and `end` observably different, which is the whole point. import type { LeaveOptions, VideoTransport } from './VideoTransport'; import type { AvailableSession, CreateSessionResult, InviteTeamResult, JoinSessionResult, RemoveParticipantResult, SessionStatusResult, ShareItemResult, VideoContext, VideoSessionRecord, } from './videoContract'; interface StubSession { record: VideoSessionRecord; meeting: unknown; /** How many attendees are currently in the meeting. The real broker keys this by participant ref; * the stub has no identities, so a count is as far as it can honestly go. */ participants: number; } export function createStubVideoTransport(): VideoTransport { const sessions = new Map(); let seq = 0; const nextId = (p: string) => `${p}-${++seq}`; const mustFind = (sessionId: string): StubSession => { const found = sessions.get(sessionId); if (!found) throw new Error(`Unknown session ${sessionId}`); return found; }; return { async createSession(context: VideoContext): Promise { const sessionId = nextId('stub-session'); const meeting = { MeetingId: nextId('stub-meeting'), MediaPlacement: {} }; // `active`, not `ringing` (§4.3): the response already carries an Attendee, so the host is // in their own live meeting from this moment - alone, which is a normal state. const record: VideoSessionRecord = { sessionId, status: 'active', context }; sessions.set(sessionId, { record, meeting, participants: 1 }); return { ...record, chime: { Meeting: meeting, Attendee: { AttendeeId: nextId('stub-attendee') } } }; }, async joinSession(sessionId: string): Promise { const found = mustFind(sessionId); found.record.status = 'active'; found.participants += 1; return { sessionId, chime: { Meeting: found.meeting, Attendee: { AttendeeId: nextId('stub-attendee') } } }; }, async leaveSession(sessionId: string, options?: LeaveOptions): Promise { options?.admit?.(); const found = mustFind(sessionId); found.participants = Math.max(0, found.participants - 1); // The meeting only dies when the room empties - and then the SERVER kills it, not the // caller. While anyone remains this returns 'active' and the meeting is untouched. if (found.participants === 0) found.record.status = 'ended'; return { sessionId, status: found.record.status }; }, async endSession(sessionId: string): Promise { const found = mustFind(sessionId); // HOST verb: tears it down for everyone regardless of who is still in the room. found.record.status = 'ended'; found.participants = 0; return { sessionId, status: 'ended' }; }, async removeParticipant(sessionId: string, participantRef: string): Promise { const found = mustFind(sessionId); if (!participantRef) throw new Error('A participant reference is required'); // Status is deliberately NOT touched, unlike leaveSession: removal never ends the meeting, // because the host is still in it. found.participants = Math.max(0, found.participants - 1); return { sessionId, participantRef, removed: true }; }, async shareItem(sessionId: string, reference: string | null): Promise { const found = mustFind(sessionId); const share = reference === null ? null : { reference, sharedBy: 'Practitioner/stub', startedAt: new Date().toISOString() }; found.record = { ...found.record, share }; return { sessionId, share }; }, async inviteTeam(sessionId: string, teamId: string): Promise { mustFind(sessionId); // - the real broker 400s without a teamId rather than choosing a team. A stub that // accepted a missing one could not express that distinction, and a stub that cannot express // a distinction cannot protect it (see this file's header on `endSession`). if (!teamId) throw new Error('A collaboration team id is required'); // No roster to fan out to in-memory - report the honest zero rather than faking reach. return { sessionId, reached: 0, missed: [] }; }, async repairDiscovery(sessionId: string): Promise { mustFind(sessionId); return { sessionId, reached: 0, missed: [] }; }, async listAvailable(): Promise { return [...sessions.values()] .filter((s) => s.record.status === 'active') .map((s) => ({ sessionId: s.record.sessionId, context: s.record.context })); }, async getSession(sessionId: string): Promise { return mustFind(sessionId).record; }, }; }