import { expect, it } from 'bun:test' import { gzipSync } from 'node:zlib' import { buildActivityFrame, buildAuthenticateFrame, buildEventAck, buildPingFrame, buildRequestAck, buildWebSocketUrl, decodeMessageBody, extractChatId, isMessageLossFrame, isThreadConversation, parseMentions, parseRequestFrame, type TrouterInfo, } from './trouter' const info: TrouterInfo = { socketio: 'https://pub-ent-test.trouter.teams.microsoft.com:443/', surl: 'https://pub-ent-test.trouter.teams.microsoft.com:3443/v4/f/HASH/', connectparams: { sr: 'abc', issuer: 'prod-2', sig: 'xyz' }, } it('buildWebSocketUrl targets the socket.io v1 websocket path', () => { const url = buildWebSocketUrl(info, 'SESSION123', 'endpoint-uuid') expect(url.startsWith(`${info.socketio}socket.io/1/websocket/SESSION123?`)).toBe(true) expect(url).toContain('epid=endpoint-uuid') expect(url).toContain('sr=abc') expect(url).toContain('auth=true') }) it('buildAuthenticateFrame embeds the Bearer id_token and connectparams', () => { const frame = buildAuthenticateFrame(info, 'ID_TOKEN') expect(frame.startsWith('5:::')).toBe(true) const payload = JSON.parse(frame.slice(4)) expect(payload.name).toBe('user.authenticate') expect(payload.args[0].headers.Authorization).toBe('Bearer ID_TOKEN') expect(payload.args[0].connectparams).toEqual(info.connectparams) }) it('buildActivityFrame and buildPingFrame use the sequence number', () => { expect(buildActivityFrame(3)).toBe('5:3::{"name":"user.activity","args":[{"state":"active"}]}') expect(buildPingFrame(7)).toBe('5:7::{"name":"ping"}') }) it('buildRequestAck returns a 200 ack for the request id', () => { const ack = buildRequestAck(42) expect(ack.startsWith('3:::')).toBe(true) expect(JSON.parse(ack.slice(4))).toEqual({ id: 42, status: 200, body: '' }) }) it('buildEventAck acks numbered event frames and ignores others', () => { expect(buildEventAck('5:12::{"name":"trouter.message_loss"}')).toBe('6:12::') expect(buildEventAck('5:9+::{"name":"x"}')).toBe('6:9::') expect(buildEventAck('1::')).toBeNull() expect(buildEventAck('6:::1["pong"]')).toBeNull() }) it('parseRequestFrame parses 3::: frames and rejects others', () => { const frame = '3:::{"id":1,"method":"POST","url":"/v4/f/HASH/messaging","body":"{}"}' const parsed = parseRequestFrame(frame) expect(parsed?.id).toBe(1) expect(parsed?.url).toBe('/v4/f/HASH/messaging') expect(parseRequestFrame('5:1::{}')).toBeNull() expect(parseRequestFrame('3:::not-json')).toBeNull() }) it('parseRequestFrame rejects frames without a numeric id', () => { expect(parseRequestFrame('3:::{"url":"/x/messaging"}')).toBeNull() expect(parseRequestFrame('3:::{"id":"abc"}')).toBeNull() expect(parseRequestFrame('3:::"just-a-string"')).toBeNull() expect(parseRequestFrame('3:::null')).toBeNull() }) it('isMessageLossFrame detects trouter.message_loss events', () => { expect(isMessageLossFrame('5:2::{"name":"trouter.message_loss","args":[{}]}')).toBe(true) expect(isMessageLossFrame('5:1::{"name":"trouter.connected","args":[{}]}')).toBe(false) expect(isMessageLossFrame('1::')).toBe(false) }) it('extractChatId pulls the conversation id from a link', () => { expect(extractChatId('https://notifications.skype.net/v1/users/ME/conversations/19:uni01_abc@thread.v2')).toBe( '19:uni01_abc@thread.v2', ) expect(extractChatId('https://x/conversations/19:uni01_abc@thread.v2/messages/123')).toBe('19:uni01_abc@thread.v2') expect(extractChatId(undefined)).toBeNull() expect(extractChatId('no-conversation-here')).toBeNull() }) it('decodeMessageBody parses plain JSON bodies', () => { const body = JSON.stringify({ resourceType: 'NewMessage', resource: { id: '1' } }) expect(decodeMessageBody({}, body)).toEqual({ resourceType: 'NewMessage', resource: { id: '1' } }) }) it('decodeMessageBody inflates gzip+base64 bodies', () => { const inner = JSON.stringify({ resourceType: 'NewMessage' }) const gzipped = gzipSync(Buffer.from(inner)).toString('base64') const decoded = decodeMessageBody({ 'X-Microsoft-Skype-Content-Encoding': 'gzip' }, gzipped) expect(decoded).toEqual({ resourceType: 'NewMessage' }) }) it('decodeMessageBody unwraps nested cp (gzip+base64) payloads', () => { const inner = JSON.stringify({ resourceType: 'NewMessage', resource: { content: 'hi' } }) const cp = gzipSync(Buffer.from(inner)).toString('base64') const decoded = decodeMessageBody({}, JSON.stringify({ cp })) expect(decoded).toEqual({ resourceType: 'NewMessage', resource: { content: 'hi' } }) }) it('decodeMessageBody unwraps nested gp (base64) payloads', () => { const inner = JSON.stringify({ resourceType: 'NewMessage' }) const gp = Buffer.from(inner).toString('base64') const decoded = decodeMessageBody({}, JSON.stringify({ gp })) expect(decoded).toEqual({ resourceType: 'NewMessage' }) }) it('isThreadConversation flags @thread ids and rejects 1:1 conversations', () => { expect(isThreadConversation('19:abc@thread.tacv2')).toBe(true) expect(isThreadConversation('19:abc@thread.v2')).toBe(true) expect(isThreadConversation('19:uni01_abc@unq.gbl.spaces')).toBe(false) expect(isThreadConversation('8:orgid:user@oneToOne.skype')).toBe(false) }) it('parseMentions prefers the authoritative properties.mentions array', () => { const properties = { mentions: [ { itemid: '0', mri: '8:orgid:aaa', mentionType: 'person', displayName: 'Alice' }, { itemid: '1', mri: '8:orgid:bbb', mentionType: 'person', displayName: 'Bob' }, ], } const content = 'Alice ' + 'Bob hi' expect(parseMentions(properties, content)).toEqual([ { id: '0', mri: '8:orgid:aaa', displayName: 'Alice' }, { id: '1', mri: '8:orgid:bbb', displayName: 'Bob' }, ]) }) it('parseMentions accepts JSON-stringified properties and mentions', () => { const properties = JSON.stringify({ mentions: JSON.stringify([{ itemid: '0', mri: '8:orgid:aaa', displayName: 'Alice' }]), }) expect(parseMentions(properties, '')).toEqual([{ id: '0', mri: '8:orgid:aaa', displayName: 'Alice' }]) }) it('parseMentions keeps tag mentions', () => { const properties = { mentions: [{ itemid: '0', mri: 'tag:eng', mentionType: 'tag', displayName: 'Engineering' }] } expect(parseMentions(properties, '')).toEqual([{ id: '0', mri: 'tag:eng', displayName: 'Engineering' }]) }) it('parseMentions falls back to content spans when properties are absent', () => { const content = '' + 'Alice hi' expect(parseMentions(undefined, content)).toEqual([{ id: '0', displayName: 'Alice' }]) }) it('parseMentions skips property entries missing itemid', () => { const properties = { mentions: [ { mri: '8:orgid:aaa', displayName: 'Alice' }, { itemid: '1', displayName: 'Bob' }, ], } expect(parseMentions(properties, '')).toEqual([{ id: '1', mri: undefined, displayName: 'Bob' }]) }) it('parseMentions returns an empty array for malformed data', () => { expect(parseMentions('not-json', '')).toEqual([]) expect(parseMentions({ mentions: 'not-json' }, '')).toEqual([]) expect(parseMentions(null, 'plain text with no mentions')).toEqual([]) })