diff --git a/packages/server/src/routes/federation.callStart.test.ts b/packages/server/src/routes/federation.callStart.test.ts index 80e2b080..9ba88114 100644 --- a/packages/server/src/routes/federation.callStart.test.ts +++ b/packages/server/src/routes/federation.callStart.test.ts @@ -4,6 +4,7 @@ import { drizzle } from 'drizzle-orm/better-sqlite3'; import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; +import type WebSocket from 'ws'; import * as schema from '../db/schema.js'; import { setWorkerId } from '../utils/snowflake.js'; @@ -52,6 +53,16 @@ async function importManager() { let sqlite: Database.Database; +/** Insert a minimal native user row so dmMembers FK constraints pass. */ +function seedUser(id: string): void { + testDb.insert(schema.users).values({ + id, + username: id, + passwordHash: '!test', + createdAt: Date.now(), + }).run(); +} + describe('processRelayEvents → processDmCallStartEvent', () => { beforeEach(async () => { sqlite = new Database(':memory:'); @@ -105,4 +116,133 @@ describe('processRelayEvents → processDmCallStartEvent', () => { ]); expect(cm.getFederatedCall(federatedId)).toBeUndefined(); }); + + it('Path A: zero connected local members → undeliverable, no entry created', async () => { + const { processRelayEvents } = await importSUT(); + const cm = await importManager(); + + // Arrange: local DM channel with federated_id matches; two local members, both offline + const federatedId = 'fed-call-pathA-all-offline'; + const dmChannelId = 'dm-1'; + seedUser('bob-local'); + seedUser('alice-local'); + testDb.insert(schema.dmChannels).values({ + id: dmChannelId, + ownerId: null, + federatedId, + createdAt: Date.now(), + }).run(); + testDb.insert(schema.dmMembers).values([ + { dmChannelId, userId: 'bob-local' }, + { dmChannelId, userId: 'alice-local' }, + ]).run(); + + // Stub: both users offline (zero WS connections) + vi.spyOn(cm, 'getUserConnections').mockImplementation(() => new Set()); + + const event = { + eventType: 'dm_call_start' as const, + messageId: 'msg-2', + encryptionVersion: 0 as const, + timestamp: Date.now(), + federatedId, + call: { + livekitUrl: 'wss://lk.example', + tokens: { + 'caller-home': 'tok-c', + 'bob-local': 'tok-b', + 'alice-local': 'tok-a', + }, + caller: { + homeUserId: 'caller-home', + homeInstance: 'https://remote.example', + displayName: 'Caller', + }, + participants: [], + }, + }; + + const sendToUserSpy = vi.spyOn(cm, 'sendToUser'); + + const result = await processRelayEvents([event], 'https://remote.example', 'https://remote.example', testDb); + + expect(result.accepted).toEqual([]); + expect(result.undeliverable).toEqual([{ messageId: 'msg-2', reason: 'no_recipient' }]); + // No dm_call_incoming dispatched to anyone + expect(sendToUserSpy).not.toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ type: 'dm_call_incoming' }), + ); + // No FederatedCallEntry + expect(cm.getFederatedCall(federatedId)).toBeUndefined(); + }); + + it('Path A: mixed online + offline rings only connected members', async () => { + const { processRelayEvents } = await importSUT(); + const cm = await importManager(); + + const federatedId = 'fed-call-pathA-mixed'; + const dmChannelId = 'dm-2'; + seedUser('bob-local'); + seedUser('carol-local'); + testDb.insert(schema.dmChannels).values({ + id: dmChannelId, + ownerId: null, + federatedId, + createdAt: Date.now(), + }).run(); + testDb.insert(schema.dmMembers).values([ + { dmChannelId, userId: 'bob-local' }, + { dmChannelId, userId: 'carol-local' }, + ]).run(); + + // Bob online, Carol offline + vi.spyOn(cm, 'getUserConnections').mockImplementation((uid: string) => + uid === 'bob-local' ? new Set(['fakews' as unknown as WebSocket]) : new Set(), + ); + + const sendToUserSpy = vi.spyOn(cm, 'sendToUser'); + + const event = { + eventType: 'dm_call_start' as const, + messageId: 'msg-3', + encryptionVersion: 0 as const, + timestamp: Date.now(), + federatedId, + call: { + livekitUrl: 'wss://lk.example', + tokens: { + 'caller-home': 'tok-c', + 'bob-local': 'tok-b', + 'carol-local': 'tok-car', + }, + caller: { + homeUserId: 'caller-home', + homeInstance: 'https://remote.example', + displayName: 'Caller', + }, + participants: [], + }, + }; + + const result = await processRelayEvents([event], 'https://remote.example', 'https://remote.example', testDb); + + expect(result.accepted).toEqual(['msg-3']); + expect(result.undeliverable).toEqual([]); + + // Bob was rung + expect(sendToUserSpy).toHaveBeenCalledWith( + 'bob-local', + expect.objectContaining({ type: 'dm_call_incoming', livekitToken: 'tok-b' }), + ); + // Carol was NOT rung (offline) + expect(sendToUserSpy).not.toHaveBeenCalledWith( + 'carol-local', + expect.anything(), + ); + + const entry = cm.getFederatedCall(federatedId); + expect(entry).toBeDefined(); + expect(entry!.ringedUserIds).toEqual(['bob-local']); + }); }); diff --git a/packages/server/src/routes/federation.ts b/packages/server/src/routes/federation.ts index 499500a6..b14a9121 100644 --- a/packages/server/src/routes/federation.ts +++ b/packages/server/src/routes/federation.ts @@ -4312,6 +4312,11 @@ function processDmCallStartEvent( // Bug 1 fix: don't ring the caller on this instance if (homeUserId === event.call.caller.homeUserId) continue; + // #18: skip offline members. Entry-vs-no-entry decision uses the same + // connection-count signal Path B has always used — keeps the two paths + // symmetric in what counts as "ringed." + if (connectionManager.getUserConnections(member.userId).size === 0) continue; + const token = event.call!.tokens![homeUserId]; connectionManager.sendToUser(member.userId, { type: 'dm_call_incoming', @@ -4326,6 +4331,14 @@ function processDmCallStartEvent( ringedUserIds.push(member.userId); } + if (ringedUserIds.length === 0) { + // #18: no local member was reachable. Do not create a FederatedCallEntry + // (it would strand with no accept/reject path); surface to the caller + // via undeliverable so it can tear down its ring room instead of hanging. + undeliverable.push({ messageId: event.messageId, reason: 'no_recipient' }); + return; + } + const entry: FederatedCallEntry = { dmChannelId: localDmChannelId, federatedId: event.federatedId,