import { EventEmitter } from "node:events"; import { test } from "node:test"; import assert from "node:assert/strict"; import { xml, type Client, type Element } from "@xmpp/client"; import type { ChatDB } from "../src/store/db.ts"; import { Muc, accountIdentity, type RoomState } from "../src/sync/muc.ts"; class FakeClient extends EventEmitter { sent: Element[] = []; async send(stanza: Element): Promise { this.sent.push(stanza); } } test("mediated invite has an id, does not edit affiliation, and surfaces a matching room error", async () => { const client = new FakeClient(); const muc = new Muc(client as unknown as Client, {} as ChatDB, "me@0ff.space", "muc.0ff.space", { room() {} }); const invited = muc.invite("test@muc.0ff.space", "friend@example.org"); await Promise.resolve(); assert.equal(client.sent.length, 1); const stanza = client.sent[0]; assert.equal(stanza.name, "message"); assert.equal(stanza.attrs.to, "test@muc.0ff.space"); assert.ok(stanza.attrs.id); assert.equal(stanza.getChild("query", "http://jabber.org/protocol/muc#admin"), undefined); assert.equal(stanza.getChild("x", "http://jabber.org/protocol/muc#user")?.getChild("invite")?.attrs.to, "friend@example.org"); client.emit( "stanza", xml( "message", { from: "test@muc.0ff.space", id: stanza.attrs.id, type: "error" }, xml("error", { type: "auth" }, xml("forbidden", { xmlns: "urn:ietf:params:xml:ns:xmpp-stanzas" })), ), ); await assert.rejects(invited, /not allowed to invite/i); }); test("a disconnect while inviting rejects instead of silently counting as sent", async () => { const client = new FakeClient(); const muc = new Muc(client as unknown as Client, {} as ChatDB, "me@0ff.space", "muc.0ff.space", { room() {} }); const invited = muc.invite("test@muc.0ff.space", "friend@example.org"); await Promise.resolve(); muc.reset(); await assert.rejects(invited, /disconnected/); }); const room = 'test@muc.0ff.space'; const presence = (nick: string, attrs: Record = {}, codes: string[] = [], type?: string) => xml('presence',{from:room+'/'+nick,...(type?{type}:{})},xml('x',{xmlns:'http://jabber.org/protocol/muc#user'},xml('item',attrs),...codes.map(code=>xml('status',{code})))); test('a transport closing during join rejects promptly without an unhandled send',async()=>{ const client = new FakeClient(); client.send=async()=>{throw Error('Connection is closing');}; const muc=new Muc(client as unknown as Client,{} as ChatDB,'me@0ff.space','muc.0ff.space',{room(){}}); await assert.rejects(muc.join(room,'Me'),/Connection closed while joining/); }); test('participant records track roles, disclosed identities, changes, rename and reconnect without counting unknown presence',async()=>{ const client = new FakeClient(); let state: RoomState | undefined; const muc = new Muc(client as unknown as Client,{} as ChatDB,'me@0ff.space','muc.0ff.space',{room(_jid,s){state=s;}}); const joining=muc.join(room,'Me'); client.emit('stanza',presence('Me',{role:'moderator',affiliation:'owner',jid:'me@0ff.space/web'},['110'])); await joining; client.emit('stanza',presence('Peer',{role:'participant',affiliation:'member',jid:'peer@0ff.space/mobile'})); assert.equal(state!.occupants,2);assert.equal(state!.participants[1].jid,'peer@0ff.space'); client.emit('stanza',presence('Peer',{role:'moderator',affiliation:'admin'})); assert.equal(state!.participants[1].role,'moderator'); assert.equal(state!.participants[1].jid,undefined); // no stale identity after disclosure changes client.emit('stanza',xml('presence',{from:room+'/unknown'})); assert.equal(state!.occupants,2); client.emit('stanza',presence('Peer',{nick:'New nick',role:'moderator',affiliation:'admin'},['303'],'unavailable')); assert.deepEqual(state!.participants.map(p=>p.nick),['Me','New nick']); client.emit('stanza',presence('New nick',{role:'participant',affiliation:'member'})); assert.equal(state!.occupants,2); client.emit('stanza',presence('Me',{nick:'Me too',role:'moderator',affiliation:'owner'},['110','303'],'unavailable')); assert.equal(state!.nick,'Me too');assert.equal(state!.joined,true); client.emit('stanza',presence('New nick',{},[],'unavailable'));assert.equal(state!.occupants,1); muc.reset();assert.equal(state!.occupants,0);assert.deepEqual(state!.participants,[]);assert.equal(state!.joined,false); const again=muc.join(room,'Me too');client.emit('stanza',presence('Me too',{role:'moderator',affiliation:'owner'},['110']));await again; client.emit('stanza',presence('Me too',{},['110','307'],'unavailable')); assert.equal(state!.joined,false);assert.deepEqual(state!.participants,[]); }); test('all three affiliation lists are requested separately; forbidden, malformed and empty differ',async()=>{ const client = new FakeClient() as FakeClient & {iqCaller:Client['iqCaller']}; const requests:string[]=[]; client.iqCaller={get:async(query,to)=>{ assert.equal(to,room);assert.equal(query.attrs.xmlns,'http://jabber.org/protocol/muc#admin'); const affiliation=query.getChild('item')!.attrs.affiliation;requests.push(affiliation); if(affiliation==='admin')throw {condition:'forbidden',message:'denied'}; return xml('query',{xmlns:'http://jabber.org/protocol/muc#admin'}, ...(affiliation==='member'?[xml('item',{affiliation,jid:'absent@0ff.space/phone'}),xml('item',{affiliation,jid:'absent@0ff.space'})]:[])); },set:async()=>undefined,request:async()=>xml('iq')}; const muc=new Muc(client,{} as ChatDB,'me@0ff.space','muc.0ff.space',{room(){}}); const lists=await muc.memberLists(room); assert.deepEqual(requests,['member','admin','owner']); assert.deepEqual(lists.map(l=>l.state),['ready','forbidden','ready']); assert.deepEqual(lists[0].members,[{jid:'absent@0ff.space',nick:undefined,affiliation:'member'}]); assert.deepEqual(lists[2].members,[]); client.iqCaller.get=async()=>xml('query',{xmlns:'http://jabber.org/protocol/muc#admin'},xml('item',{jid:'bad'})); assert.ok((await muc.memberLists(room)).every(l=>l.state==='error')); assert.equal(accountIdentity('room-nickname'),undefined); assert.equal(accountIdentity('peer@example.org/Resource'),'peer@example.org'); });