diff --git a/src/app/plugins/matrix-to.test.ts b/src/app/plugins/matrix-to.test.ts new file mode 100644 index 000000000..fb57b1c76 --- /dev/null +++ b/src/app/plugins/matrix-to.test.ts @@ -0,0 +1,77 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { + getMatrixToUser, + getMatrixToRoom, + getMatrixToRoomEvent, + testMatrixTo, + parseMatrixToUser, + parseMatrixToRoom, + parseMatrixToRoomEvent, +} from './matrix-to'; + +test('getMatrixToUser builds a user permalink', () => { + assert.equal(getMatrixToUser('@alice:example.org'), 'https://matrix.to/#/@alice:example.org'); +}); + +test('getMatrixToRoom builds room links with optional via servers', () => { + assert.equal(getMatrixToRoom('#room:example.org'), 'https://matrix.to/#/#room:example.org'); + assert.equal( + getMatrixToRoom('!abc:example.org', ['a.org', 'b.org']), + 'https://matrix.to/#/!abc:example.org?via=a.org&via=b.org', + ); + // empty via array → no query string + assert.equal(getMatrixToRoom('!abc:example.org', []), 'https://matrix.to/#/!abc:example.org'); +}); + +test('getMatrixToRoomEvent builds event links', () => { + assert.equal( + getMatrixToRoomEvent('!abc:example.org', '$evt', ['a.org']), + 'https://matrix.to/#/!abc:example.org/$evt?via=a.org', + ); + assert.equal( + getMatrixToRoomEvent('!abc:example.org', '$evt'), + 'https://matrix.to/#/!abc:example.org/$evt', + ); +}); + +test('testMatrixTo recognizes matrix.to hrefs', () => { + assert.equal(testMatrixTo('https://matrix.to/#/@a:b.org'), true); + assert.equal(testMatrixTo('http://matrix.to/#/!r:b.org'), true); + assert.equal(testMatrixTo('https://example.org/#/@a:b.org'), false); +}); + +test('parseMatrixToUser round-trips and rejects non-user links', () => { + assert.equal(parseMatrixToUser(getMatrixToUser('@a:b.org')), '@a:b.org'); + assert.equal(parseMatrixToUser('https://matrix.to/#/@a:b.org/'), '@a:b.org'); // trailing slash ok + assert.equal(parseMatrixToUser('https://matrix.to/#/#room:b.org'), undefined); + assert.equal(parseMatrixToUser('https://example.org'), undefined); +}); + +test('parseMatrixToRoom extracts alias/id and via servers', () => { + assert.deepEqual(parseMatrixToRoom('https://matrix.to/#/#room:b.org'), { + roomIdOrAlias: '#room:b.org', + viaServers: undefined, + }); + assert.deepEqual(parseMatrixToRoom(getMatrixToRoom('!abc:b.org', ['a.org', 'c.org'])), { + roomIdOrAlias: '!abc:b.org', + viaServers: ['a.org', 'c.org'], + }); + // a user link is not a room link + assert.equal(parseMatrixToRoom('https://matrix.to/#/@a:b.org'), undefined); +}); + +test('parseMatrixToRoomEvent extracts room, event, and via servers', () => { + assert.deepEqual(parseMatrixToRoomEvent(getMatrixToRoomEvent('!abc:b.org', '$e', ['a.org'])), { + roomIdOrAlias: '!abc:b.org', + eventId: '$e', + viaServers: ['a.org'], + }); + assert.deepEqual(parseMatrixToRoomEvent('https://matrix.to/#/!abc:b.org/$e'), { + roomIdOrAlias: '!abc:b.org', + eventId: '$e', + viaServers: undefined, + }); + // a room-only link has no event + assert.equal(parseMatrixToRoomEvent('https://matrix.to/#/!abc:b.org'), undefined); +}); diff --git a/src/app/utils/room.test.ts b/src/app/utils/room.test.ts new file mode 100644 index 000000000..8084562fb --- /dev/null +++ b/src/app/utils/room.test.ts @@ -0,0 +1,582 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { + EventTimeline, + JoinRule, + MatrixEvent, + NotificationCountType, + Room, + RoomMember, +} from 'matrix-js-sdk'; +import { + getStateEvent, + getStateEvents, + getMDirects, + isDirectInvite, + isSpace, + isRoom, + isUnsupportedRoom, + isValidChild, + getAllParents, + getSpaceChildren, + mapParentWithChildren, + getOrphanParents, + isMutedRule, + findMutedRule, + isNotificationEvent, + roomHaveNotification, + getUnreadInfo, + getRoomIconSrc, + trimReplyFromBody, + trimReplyFromFormattedBody, + parseReplyBody, + parseReplyFormattedBody, + getMemberDisplayName, + getMemberSearchStr, + getMemberAvatarMxc, + isMembershipChanged, + getReactionContent, + getLatestEdit, + reactionOrEditEvent, + getMentionContent, + getAllVersionsRoomCreator, +} from './room'; +import { RoomType, StateEvent, RoomToParents } from '../../types/matrix/room'; + +// --- Mock helpers --------------------------------------------------------- + +const mockEvent = (overrides: Partial> = {}): MatrixEvent => { + const base: Record unknown> = { + getType: () => undefined, + getContent: () => ({}), + getPrevContent: () => ({}), + getStateKey: () => '', + getSender: () => undefined, + getTs: () => 0, + getId: () => undefined, + getRelation: () => null, + isRedacted: () => false, + }; + return { ...base, ...overrides } as unknown as MatrixEvent; +}; + +// Build a Room whose live-timeline forward state returns the given state events. +const mockRoomWithState = ( + stateEventsByType: Record, + rest: Record = {}, +): Room => { + const state = { + getStateEvents: (eventType: string, stateKey?: string) => { + const events = stateEventsByType[eventType] ?? []; + if (stateKey !== undefined) { + return events[0]; // single-event form + } + return events; // array form + }, + }; + return { + getLiveTimeline: () => ({ + getState: () => state, + }), + ...rest, + } as unknown as Room; +}; + +// --- getStateEvent / getStateEvents -------------------------------------- + +test('getStateEvent returns the matching state event or undefined', () => { + const ev = mockEvent({ getType: () => StateEvent.RoomName }); + const room = mockRoomWithState({ [StateEvent.RoomName]: [ev] }); + assert.equal(getStateEvent(room, StateEvent.RoomName), ev); + + const empty = mockRoomWithState({}); + assert.equal(getStateEvent(empty, StateEvent.RoomName), undefined); +}); + +test('getStateEvent returns undefined when there is no forward state', () => { + const room = { + getLiveTimeline: () => ({ getState: () => undefined }), + } as unknown as Room; + assert.equal(getStateEvent(room, StateEvent.RoomName), undefined); +}); + +test('getStateEvents returns an array, empty when none', () => { + const ev = mockEvent(); + const room = mockRoomWithState({ [StateEvent.SpaceChild]: [ev] }); + assert.deepEqual(getStateEvents(room, StateEvent.SpaceChild), [ev]); + + const empty = mockRoomWithState({}); + assert.deepEqual(getStateEvents(empty, StateEvent.SpaceChild), []); +}); + +// --- getMDirects ---------------------------------------------------------- + +test('getMDirects collects all direct room ids across users', () => { + const ev = mockEvent({ + getContent: () => ({ + '@a:x': ['!room1', '!room2'], + '@b:x': ['!room3'], + }), + }); + const result = getMDirects(ev); + assert.deepEqual([...result].sort(), ['!room1', '!room2', '!room3']); +}); + +test('getMDirects ignores non-array and non-string entries', () => { + const ev = mockEvent({ + getContent: () => ({ + '@a:x': 'not-an-array', + '@b:x': ['!ok', 42, null], + }), + }); + const result = getMDirects(ev); + assert.deepEqual([...result], ['!ok']); +}); + +test('getMDirects returns empty set when content is undefined', () => { + const ev = mockEvent({ getContent: () => undefined }); + assert.equal(getMDirects(ev).size, 0); +}); + +// --- isDirectInvite ------------------------------------------------------- + +test('isDirectInvite returns false for null room or null user', () => { + assert.equal(isDirectInvite(null, '@me:x'), false); + assert.equal(isDirectInvite({} as unknown as Room, null), false); +}); + +test('isDirectInvite reads is_direct from the member event content', () => { + const room = { + getMember: () => ({ + events: { member: { getContent: () => ({ is_direct: true }) } }, + }), + } as unknown as Room; + assert.equal(isDirectInvite(room, '@me:x'), true); + + const roomNotDirect = { + getMember: () => ({ + events: { member: { getContent: () => ({}) } }, + }), + } as unknown as Room; + assert.equal(isDirectInvite(roomNotDirect, '@me:x'), false); +}); + +test('isDirectInvite returns false when member is missing', () => { + const room = { getMember: () => null } as unknown as Room; + assert.equal(isDirectInvite(room, '@me:x'), false); +}); + +// --- isSpace / isRoom / isUnsupportedRoom -------------------------------- + +test('isSpace detects space rooms via create event type', () => { + const spaceEv = mockEvent({ getContent: () => ({ type: RoomType.Space }) }); + assert.equal(isSpace(mockRoomWithState({ [StateEvent.RoomCreate]: [spaceEv] })), true); + + const normalEv = mockEvent({ getContent: () => ({}) }); + assert.equal(isSpace(mockRoomWithState({ [StateEvent.RoomCreate]: [normalEv] })), false); + + assert.equal(isSpace(null), false); + assert.equal(isSpace(mockRoomWithState({})), false); // no create event +}); + +test('isRoom is true for normal rooms and rooms with no create event', () => { + const spaceEv = mockEvent({ getContent: () => ({ type: RoomType.Space }) }); + assert.equal(isRoom(mockRoomWithState({ [StateEvent.RoomCreate]: [spaceEv] })), false); + + const normalEv = mockEvent({ getContent: () => ({}) }); + assert.equal(isRoom(mockRoomWithState({ [StateEvent.RoomCreate]: [normalEv] })), true); + + assert.equal(isRoom(mockRoomWithState({})), true); // no create event -> treated as room + assert.equal(isRoom(null), false); +}); + +test('isUnsupportedRoom is true when type is set and not a space', () => { + const callEv = mockEvent({ getContent: () => ({ type: RoomType.Call }) }); + assert.equal(isUnsupportedRoom(mockRoomWithState({ [StateEvent.RoomCreate]: [callEv] })), true); + + const spaceEv = mockEvent({ getContent: () => ({ type: RoomType.Space }) }); + assert.equal(isUnsupportedRoom(mockRoomWithState({ [StateEvent.RoomCreate]: [spaceEv] })), false); + + const normalEv = mockEvent({ getContent: () => ({}) }); + assert.equal( + isUnsupportedRoom(mockRoomWithState({ [StateEvent.RoomCreate]: [normalEv] })), + false, + ); + + assert.equal(isUnsupportedRoom(mockRoomWithState({})), true); // missing create event + assert.equal(isUnsupportedRoom(null), false); +}); + +// --- isValidChild / getSpaceChildren ------------------------------------- + +test('isValidChild requires SpaceChild type with via array', () => { + const valid = mockEvent({ + getType: () => StateEvent.SpaceChild, + getContent: () => ({ via: ['server'] }), + }); + assert.equal(isValidChild(valid), true); + + const noVia = mockEvent({ + getType: () => StateEvent.SpaceChild, + getContent: () => ({}), + }); + assert.equal(isValidChild(noVia), false); + + const wrongType = mockEvent({ + getType: () => StateEvent.RoomName, + getContent: () => ({ via: ['server'] }), + }); + assert.equal(isValidChild(wrongType), false); +}); + +test('getSpaceChildren returns state keys of valid child events', () => { + const valid = mockEvent({ + getType: () => StateEvent.SpaceChild, + getStateKey: () => '!child:x', + getContent: () => ({ via: ['s'] }), + }); + const invalid = mockEvent({ + getType: () => StateEvent.SpaceChild, + getStateKey: () => '!bad:x', + getContent: () => ({}), + }); + const noKey = mockEvent({ + getType: () => StateEvent.SpaceChild, + getStateKey: () => '', + getContent: () => ({ via: ['s'] }), + }); + const room = mockRoomWithState({ + [StateEvent.SpaceChild]: [valid, invalid, noKey], + }); + assert.deepEqual(getSpaceChildren(room), ['!child:x']); +}); + +// --- getAllParents / mapParentWithChildren / getOrphanParents ------------ + +test('getAllParents walks the parent graph excluding the start room', () => { + const map: RoomToParents = new Map([ + ['child', new Set(['mid'])], + ['mid', new Set(['root'])], + ]); + assert.deepEqual([...getAllParents(map, 'child')].sort(), ['mid', 'root']); + assert.deepEqual([...getAllParents(map, 'root')], []); +}); + +test('getAllParents handles cycles without infinite recursion', () => { + const map: RoomToParents = new Map([ + ['a', new Set(['b'])], + ['b', new Set(['a'])], + ]); + assert.deepEqual([...getAllParents(map, 'a')], ['b']); +}); + +test('mapParentWithChildren registers parent for each child', () => { + const map: RoomToParents = new Map(); + mapParentWithChildren(map, 'space1', ['c1', 'c2']); + assert.deepEqual([...(map.get('c1') ?? [])], ['space1']); + assert.deepEqual([...(map.get('c2') ?? [])], ['space1']); +}); + +test('mapParentWithChildren skips children that would create a cycle', () => { + const map: RoomToParents = new Map([['space1', new Set(['ancestor'])]]); + mapParentWithChildren(map, 'space1', ['ancestor', 'newchild']); + // ancestor is an existing parent -> cycle -> skipped + assert.equal(map.has('ancestor'), false); + assert.deepEqual([...(map.get('newchild') ?? [])], ['space1']); +}); + +test('getOrphanParents returns parents not present as keys in the map', () => { + const map: RoomToParents = new Map([ + ['child', new Set(['mid'])], + ['mid', new Set(['root'])], + ]); + // root is a parent but has no entry of its own -> orphan + assert.deepEqual(getOrphanParents(map, 'child'), ['root']); +}); + +// --- isMutedRule / findMutedRule ----------------------------------------- + +test('isMutedRule matches empty actions or dont_notify with event_match', () => { + assert.equal( + isMutedRule({ + actions: [], + conditions: [{ kind: 'event_match' }], + } as never), + true, + ); + assert.equal( + isMutedRule({ + actions: ['dont_notify'], + conditions: [{ kind: 'event_match' }], + } as never), + true, + ); + assert.equal( + isMutedRule({ + actions: ['notify'], + conditions: [{ kind: 'event_match' }], + } as never), + false, + ); + assert.ok( + !isMutedRule({ + actions: [], + conditions: [{ kind: 'other' }], + } as never), + ); +}); + +test('findMutedRule finds rule by id that is also muted', () => { + const rules = [ + { rule_id: '!a', actions: [], conditions: [{ kind: 'event_match' }] }, + { rule_id: '!b', actions: ['notify'], conditions: [{ kind: 'event_match' }] }, + ] as never[]; + assert.equal(findMutedRule(rules, '!a'), rules[0]); + assert.equal(findMutedRule(rules, '!b'), undefined); + assert.equal(findMutedRule(rules, '!missing'), undefined); +}); + +// --- isNotificationEvent -------------------------------------------------- + +test('isNotificationEvent accepts message/sticker but rejects member, redacted, edits', () => { + assert.equal(isNotificationEvent(mockEvent({ getType: () => 'm.room.message' })), true); + assert.equal(isNotificationEvent(mockEvent({ getType: () => 'm.sticker' })), true); + assert.equal(isNotificationEvent(mockEvent({ getType: () => 'm.room.member' })), false); + assert.equal(isNotificationEvent(mockEvent({ getType: () => 'm.room.topic' })), false); + + assert.equal( + isNotificationEvent(mockEvent({ getType: () => 'm.room.message', isRedacted: () => true })), + false, + ); + assert.equal( + isNotificationEvent( + mockEvent({ + getType: () => 'm.room.message', + getRelation: () => ({ rel_type: 'm.replace' }), + }), + ), + false, + ); +}); + +// --- roomHaveNotification / getUnreadInfo -------------------------------- + +test('roomHaveNotification true when total or highlight count > 0', () => { + const make = (total: number, highlight: number) => + ({ + getUnreadNotificationCount: (type: NotificationCountType) => + type === NotificationCountType.Total ? total : highlight, + }) as unknown as Room; + assert.equal(roomHaveNotification(make(0, 0)), false); + assert.equal(roomHaveNotification(make(3, 0)), true); + assert.equal(roomHaveNotification(make(0, 1)), true); +}); + +test('getUnreadInfo uses highlight when it exceeds total', () => { + const room = { + roomId: '!r:x', + getUnreadNotificationCount: (type: NotificationCountType) => + type === NotificationCountType.Total ? 2 : 5, + } as unknown as Room; + assert.deepEqual(getUnreadInfo(room), { roomId: '!r:x', highlight: 5, total: 5 }); + + const room2 = { + roomId: '!r:y', + getUnreadNotificationCount: (type: NotificationCountType) => + type === NotificationCountType.Total ? 7 : 1, + } as unknown as Room; + assert.deepEqual(getUnreadInfo(room2), { roomId: '!r:y', highlight: 1, total: 7 }); +}); + +// --- getRoomIconSrc ------------------------------------------------------- + +test('getRoomIconSrc selects icon by room type and join rule', () => { + const icons = { + Warning: 'warning', + SpaceGlobe: 'space-globe', + SpaceLock: 'space-lock', + Space: 'space', + VolumeHighGlobe: 'vol-globe', + VolumeHighLock: 'vol-lock', + VolumeHigh: 'vol', + HashGlobe: 'hash-globe', + HashLock: 'hash-lock', + Hash: 'hash', + } as never; + + assert.equal(getRoomIconSrc(icons, 'm.server_notice'), 'warning'); + assert.equal(getRoomIconSrc(icons, RoomType.Space, JoinRule.Public), 'space-globe'); + assert.equal(getRoomIconSrc(icons, RoomType.Space, JoinRule.Invite), 'space-lock'); + assert.equal(getRoomIconSrc(icons, RoomType.Space), 'space'); + assert.equal(getRoomIconSrc(icons, RoomType.Call, JoinRule.Public), 'vol-globe'); + assert.equal(getRoomIconSrc(icons, RoomType.Call, JoinRule.Knock), 'vol-lock'); + assert.equal(getRoomIconSrc(icons, RoomType.Call), 'vol'); + assert.equal(getRoomIconSrc(icons, undefined, JoinRule.Public), 'hash-globe'); + assert.equal(getRoomIconSrc(icons, undefined, JoinRule.Invite), 'hash-lock'); + assert.equal(getRoomIconSrc(icons), 'hash'); +}); + +// --- reply parsing helpers ----------------------------------------------- + +test('trimReplyFromBody strips a matrix reply fallback prefix', () => { + const body = '> <@a:x> hello\n> more\n\nactual message'; + assert.equal(trimReplyFromBody(body), 'actual message'); + // no fallback -> unchanged + assert.equal(trimReplyFromBody('plain text'), 'plain text'); +}); + +test('trimReplyFromFormattedBody strips up to and including mx-reply', () => { + const fb = '
x
real'; + assert.equal(trimReplyFromFormattedBody(fb), 'real'); + assert.equal(trimReplyFromFormattedBody('no reply here'), 'no reply here'); +}); + +test('parseReplyBody builds a quoted fallback with prefixed newlines', () => { + assert.equal(parseReplyBody('@a:x', 'line1\nline2'), '> <@a:x> line1\n> line2\n\n'); +}); + +test('parseReplyFormattedBody builds an mx-reply block with encoded links', () => { + const result = parseReplyFormattedBody('!r:x', '@a:x', '$e:x', 'hi'); + assert.ok(result.startsWith('
')); + assert.ok(result.endsWith('hi
')); + assert.ok(result.includes(encodeURIComponent('!r:x'))); + assert.ok(result.includes(encodeURIComponent('$e:x'))); + assert.ok(result.includes(encodeURIComponent('@a:x'))); +}); + +// --- member helpers ------------------------------------------------------- + +test('getMemberDisplayName returns name unless it equals the userId', () => { + const room = { + getMember: (id: string) => + id === '@named:x' ? { rawDisplayName: 'Alice' } : { rawDisplayName: '@plain:x' }, + } as unknown as Room; + assert.equal(getMemberDisplayName(room, '@named:x'), 'Alice'); + assert.equal(getMemberDisplayName(room, '@plain:x'), undefined); + + const noMember = { getMember: () => null } as unknown as Room; + assert.equal(getMemberDisplayName(noMember, '@x:x'), undefined); +}); + +test('getMemberSearchStr produces display and id variants', () => { + const member = { + rawDisplayName: 'Alice', + userId: '@alice:x', + } as unknown as RoomMember; + const mxIdToName = (id: string) => `name(${id})`; + + // query without @ or : -> uses mapped name for second entry + assert.deepEqual(getMemberSearchStr(member, 'al', mxIdToName), ['Alice', 'name(@alice:x)']); + // query with @ -> uses raw userId for second entry + assert.deepEqual(getMemberSearchStr(member, '@al', mxIdToName), ['Alice', '@alice:x']); + + // when display name equals userId, first entry falls back to mapped name + const unnamed = { rawDisplayName: '@bob:x', userId: '@bob:x' } as unknown as RoomMember; + assert.deepEqual(getMemberSearchStr(unnamed, 'bob', mxIdToName), [ + 'name(@bob:x)', + 'name(@bob:x)', + ]); +}); + +test('getMemberAvatarMxc returns member mxc or undefined', () => { + const room = { + getMember: (id: string) => (id === '@a:x' ? { getMxcAvatarUrl: () => 'mxc://a' } : null), + } as unknown as Room; + assert.equal(getMemberAvatarMxc(room, '@a:x'), 'mxc://a'); + assert.equal(getMemberAvatarMxc(room, '@b:x'), undefined); +}); + +// --- isMembershipChanged -------------------------------------------------- + +test('isMembershipChanged compares membership and reason against prev content', () => { + const changedMembership = mockEvent({ + getContent: () => ({ membership: 'join' }), + getPrevContent: () => ({ membership: 'invite' }), + }); + assert.equal(isMembershipChanged(changedMembership), true); + + const changedReason = mockEvent({ + getContent: () => ({ membership: 'leave', reason: 'spam' }), + getPrevContent: () => ({ membership: 'leave' }), + }); + assert.equal(isMembershipChanged(changedReason), true); + + const unchanged = mockEvent({ + getContent: () => ({ membership: 'join', reason: 'x' }), + getPrevContent: () => ({ membership: 'join', reason: 'x' }), + }); + assert.equal(isMembershipChanged(unchanged), false); +}); + +// --- getReactionContent / getMentionContent ------------------------------ + +test('getReactionContent builds an annotation relation', () => { + assert.deepEqual(getReactionContent('$e:x', '👍', 'thumbsup'), { + 'm.relates_to': { + event_id: '$e:x', + key: '👍', + rel_type: 'm.annotation', + }, + shortcode: 'thumbsup', + }); +}); + +test('getMentionContent includes user_ids and room only when relevant', () => { + assert.deepEqual(getMentionContent(['@a:x'], true), { + user_ids: ['@a:x'], + room: true, + }); + assert.deepEqual(getMentionContent([], false), {}); + assert.deepEqual(getMentionContent(['@a:x'], false), { user_ids: ['@a:x'] }); + assert.deepEqual(getMentionContent([], true), { room: true }); +}); + +// --- getLatestEdit / reactionOrEditEvent --------------------------------- + +test('getLatestEdit returns newest edit from the same sender', () => { + const target = mockEvent({ getSender: () => '@a:x' }); + const oldEdit = mockEvent({ getSender: () => '@a:x', getTs: () => 100 }); + const newEdit = mockEvent({ getSender: () => '@a:x', getTs: () => 200 }); + const otherSender = mockEvent({ getSender: () => '@b:x', getTs: () => 300 }); + + assert.equal(getLatestEdit(target, [oldEdit, newEdit, otherSender]), newEdit); + assert.equal(getLatestEdit(target, [otherSender]), undefined); +}); + +test('reactionOrEditEvent detects annotation and replace relations', () => { + assert.equal( + reactionOrEditEvent(mockEvent({ getRelation: () => ({ rel_type: 'm.annotation' }) })), + true, + ); + assert.equal( + reactionOrEditEvent(mockEvent({ getRelation: () => ({ rel_type: 'm.replace' }) })), + true, + ); + assert.equal( + reactionOrEditEvent(mockEvent({ getRelation: () => ({ rel_type: 'm.thread' }) })), + false, + ); + assert.equal(reactionOrEditEvent(mockEvent({ getRelation: () => null })), false); +}); + +// --- getAllVersionsRoomCreator ------------------------------------------- + +test('getAllVersionsRoomCreator collects sender and additional_creators', () => { + const createEv = mockEvent({ + getSender: () => '@creator:x', + getContent: () => ({ additional_creators: ['@co1:x', '@co2:x', 5] }), + }); + const room = mockRoomWithState({ [StateEvent.RoomCreate]: [createEv] }); + const creators = getAllVersionsRoomCreator(room); + assert.deepEqual([...creators].sort(), ['@co1:x', '@co2:x', '@creator:x']); +}); + +test('getAllVersionsRoomCreator returns empty set when no create event', () => { + const room = mockRoomWithState({}); + assert.equal(getAllVersionsRoomCreator(room).size, 0); +}); + +// Reference EventTimeline so the import is used even though state mocks bypass it. +test('EventTimeline.FORWARDS constant is available', () => { + assert.equal(typeof EventTimeline.FORWARDS, 'string'); +});