// SPDX-License-Identifier: AGPL-3.0-or-later import {Channel, type ChannelWire} from '@app/features/channel/models/Channel'; import {ChannelTypes, MessageTypes, Permissions} from '@fluxer/constants/src/ChannelConstants'; import {ThreadPermissionFlags} from '@fluxer/constants/src/ThreadPermissionUtils'; import {observable, runInAction} from 'mobx'; import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; const channelStore = new Map(); const viewable = new Set(); const moderatorParents = new Set(); const navigation = observable({ guildId: null as string | null, channelId: null as string | null, threadId: null as string | null, }); const selectedGuild = observable({selectedGuildId: null as string | null}); const updateGuildSubscriptions = vi.fn(); const gateway = observable({isReady: false, socket: {isConnected: () => true, updateGuildSubscriptions}}); vi.mock('@app/features/app/state/RuntimeConfig', () => ({default: {localInstanceDomain: 'fluxer.test'}})); vi.mock('@app/features/user/state/Users', () => ({ default: {getUser: () => undefined, cacheUsers: () => {}, getCurrentUser: () => ({id: 'me', mfaEnabled: true})}, })); vi.mock('@app/features/auth/state/Authentication', () => ({default: {currentUserId: 'me'}})); vi.mock('@app/app/Routes', () => ({ Routes: {guildChannel: (guildId: string, channelId?: string) => `/channels/${guildId}/${channelId ?? ''}`}, })); vi.mock('@app/features/channel/events/ChannelDelete', () => ({ cleanupChannelLocalState: vi.fn((channel: {id: string}) => channelStore.delete(channel.id)), })); vi.mock('@app/features/channel/state/Channels', () => ({ default: { getChannel: (id: string) => channelStore.get(id), upsertThread: (wire: ChannelWire) => channelStore.set(wire.id, new Channel(wire)), removeThread: (id: string) => channelStore.delete(id), handleChannelCreate: ({channel}: {channel: Channel | ChannelWire}) => channelStore.set(channel.id, channel instanceof Channel ? channel : new Channel(channel)), getGuildChannels: (guildId: string) => [...channelStore.values()].filter((channel) => channel.guildId === guildId && !channel.isThread()), }, })); vi.mock('@app/features/guild/state/GuildReadState', () => ({default: {handleGenericUpdate: vi.fn()}})); vi.mock('@app/features/read_state/state/ReadStates', () => ({default: {handleChannelCreate: vi.fn()}})); vi.mock('@app/features/messaging/state/MessagingMessages', () => ({ default: {handleCleanup: vi.fn(), handleGuildThreadsPurged: vi.fn()}, })); vi.mock('@app/features/ui/commands/ContextMenuCommands', () => ({close: vi.fn()})); vi.mock('@app/features/ui/commands/PopoutCommands', () => ({closeAll: vi.fn()})); vi.mock('@app/features/guild/state/Guilds', () => ({default: {getGuild: () => ({ownerId: 'owner', mfaLevel: 0})}})); vi.mock('@app/features/member/state/GuildMembers', () => ({ default: {getMember: () => null, hydrateIfMissing: () => {}}, })); vi.mock('@app/features/permissions/state/Permission', () => ({ default: { can: (_permission: bigint, channel: {id: string}) => viewable.has(channel.id), getChannelPermissions: (id: string) => { if (!viewable.has(id)) return 0n; const base = Permissions.VIEW_CHANNEL | Permissions.READ_MESSAGE_HISTORY | Permissions.SEND_MESSAGES; return moderatorParents.has(id) ? base | ThreadPermissionFlags.MANAGE_THREADS : base; }, handleChannelUpdate: vi.fn(), }, })); vi.mock('@app/features/navigation/state/Navigation', () => ({default: navigation})); vi.mock('@app/features/navigation/state/SelectedGuild', () => ({default: selectedGuild})); vi.mock('@app/features/navigation/utils/RouterUtils', () => ({replaceWith: vi.fn(), transitionTo: vi.fn()})); vi.mock('@app/features/gateway/transport/GatewayConnection', () => ({default: gateway})); vi.mock('@app/features/user/utils/DateFormatting', () => ({getFormattedFullDate: () => ''})); vi.mock('@app/features/moderation/state/LocalUserSpamOverride', () => ({ default: {isUserMarkedAsSpammer: () => false}, })); vi.mock('@app/features/platform/transport/RestTransport', () => ({ http: {get: vi.fn(), post: vi.fn(), put: vi.fn(), patch: vi.fn(), delete: vi.fn()}, })); const {default: ChannelThreads} = await import('@app/features/threads/state/ChannelThreads'); const {default: ThreadGuilds} = await import('@app/features/threads/state/ThreadGuilds'); const {default: ThreadMemberships} = await import('@app/features/threads/state/ThreadMemberships'); const {default: ThreadRoster} = await import('@app/features/threads/state/ThreadRoster'); const {default: ThreadSubscriptions} = await import('@app/features/threads/state/ThreadSubscriptions'); const {isSyncExcludedChannelId} = await import('@app/features/threads/utils/SyncedPreferenceGuard'); const {cleanupChannelLocalState} = await import('@app/features/channel/events/ChannelDelete'); const RouterUtils = await import('@app/features/navigation/utils/RouterUtils'); const {http} = await import('@app/features/platform/transport/RestTransport'); const {isNewMessageGroup} = await import('@app/features/messaging/utils/MessageGroupingUtils'); const GUILD = '1500000000000000000'; const PARENT = '1500000000000000001'; const OTHER_PARENT = '1500000000000000002'; const FORUM = '1500000000000000003'; const THREAD_A = '1500000000000000010'; const THREAD_B = '1500000000000000011'; const PRIVATE_THREAD = '1500000000000000012'; const MEMBER = {join_timestamp: '2026-09-28T00:00:00.000Z', flags: 0}; function metadata(overrides: Partial<{archived: boolean; locked: boolean}> = {}) { return { archived: false, locked: false, auto_archive_duration: 4320, archive_timestamp: '2026-09-28T00:00:00.000Z', create_timestamp: '2026-09-28T00:00:00.000Z', ...overrides, }; } function thread(id: string, overrides: Partial = {}): ChannelWire { return { id, guild_id: GUILD, parent_id: PARENT, type: ChannelTypes.PUBLIC_THREAD, name: `thread-${id}`, last_message_id: id, thread_metadata: metadata(), message_count: 0, total_message_sent: 0, ...overrides, }; } function seedParents(): void { channelStore.set(PARENT, new Channel({id: PARENT, guild_id: GUILD, type: ChannelTypes.GUILD_TEXT, name: 'general'})); channelStore.set(OTHER_PARENT, new Channel({id: OTHER_PARENT, guild_id: GUILD, type: ChannelTypes.GUILD_TEXT})); channelStore.set(FORUM, new Channel({id: FORUM, guild_id: GUILD, type: ChannelTypes.GUILD_FORUM, name: 'forum'})); viewable.add(PARENT); viewable.add(OTHER_PARENT); viewable.add(FORUM); } function guildObject(threads?: Array) { return { id: GUILD, properties: {}, channels: [], emojis: [], members: [], member_count: 0, roles: [], joined_at: '2026-09-28T00:00:00.000Z', ...(threads ? {threads} : {}), } as unknown as Parameters[0]; } beforeEach(() => { channelStore.clear(); viewable.clear(); moderatorParents.clear(); ChannelThreads.handleGatewayReady([]); ThreadGuilds.reset(); runInAction(() => { navigation.guildId = null; navigation.channelId = null; navigation.threadId = null; selectedGuild.selectedGuildId = null; gateway.isReady = false; }); updateGuildSubscriptions.mockClear(); vi.mocked(cleanupChannelLocalState).mockClear(); vi.mocked(RouterUtils.replaceWith).mockClear(); seedParents(); }); afterEach(() => { vi.mocked(http.delete).mockClear(); }); describe('ChannelThreads ingest and upsert', () => { beforeEach(() => { ThreadGuilds.handleGuild(guildObject([])); }); it('indexes READY threads by parent and records the viewer membership', () => { ChannelThreads.handleGatewayReady([ {id: GUILD, threads: [thread(THREAD_A, {member: MEMBER}), thread(THREAD_B, {parent_id: OTHER_PARENT})]}, ]); expect(ChannelThreads.getThreadIdsForParent(PARENT)).toEqual([THREAD_A]); expect(ChannelThreads.getThreadIdsForParent(OTHER_PARENT)).toEqual([THREAD_B]); expect(ChannelThreads.getGuildThreadIds(GUILD)).toEqual([THREAD_A, THREAD_B]); expect(ThreadMemberships.isMember(THREAD_A)).toBe(true); expect(ThreadMemberships.isMember(THREAD_B)).toBe(false); expect(channelStore.get(THREAD_A)?.isThread()).toBe(true); }); it('never walks the last message id back on an update', () => { ChannelThreads.upsert(thread(THREAD_A, {last_message_id: '1500000000000000099'})); ChannelThreads.upsert(thread(THREAD_A, {last_message_id: '1500000000000000050', name: 'renamed'})); expect(channelStore.get(THREAD_A)?.lastMessageId).toBe('1500000000000000099'); expect(channelStore.get(THREAD_A)?.name).toBe('renamed'); }); it('keeps archived threads out of the active and sidebar lists unless open', () => { ChannelThreads.upsert(thread(THREAD_A, {member: MEMBER})); ChannelThreads.upsert(thread(THREAD_A, {thread_metadata: metadata({archived: true})})); expect(ChannelThreads.getThreadsForParent(PARENT).map((entry) => entry.id)).toEqual([THREAD_A]); expect(ChannelThreads.getActiveThreadsForParent(PARENT)).toEqual([]); expect(ChannelThreads.getSidebarThreads(PARENT, null)).toEqual([]); expect(ChannelThreads.getSidebarThreads(PARENT, THREAD_A).map((entry) => entry.id)).toEqual([THREAD_A]); }); it('filters every getter on parent VIEW_CHANNEL', () => { ChannelThreads.upsert(thread(THREAD_A, {member: MEMBER})); viewable.delete(PARENT); expect(ChannelThreads.getThread(THREAD_A)).toBeUndefined(); expect(ChannelThreads.getThreadsForParent(PARENT)).toEqual([]); expect(ChannelThreads.getGuildThreads(GUILD)).toEqual([]); expect(ChannelThreads.getSidebarThreads(PARENT, THREAD_A)).toEqual([]); }); it('shows private threads only to members and thread moderators', () => { ChannelThreads.upsert(thread(PRIVATE_THREAD, {type: ChannelTypes.PRIVATE_THREAD})); expect(ChannelThreads.getThread(PRIVATE_THREAD)).toBeUndefined(); moderatorParents.add(PARENT); expect(ChannelThreads.getThread(PRIVATE_THREAD)?.id).toBe(PRIVATE_THREAD); moderatorParents.delete(PARENT); ChannelThreads.upsert(thread(PRIVATE_THREAD, {type: ChannelTypes.PRIVATE_THREAD, member: MEMBER})); expect(ChannelThreads.getThread(PRIVATE_THREAD)?.id).toBe(PRIVATE_THREAD); }); it('ingests the thread attached to a loaded source message', () => { ChannelThreads.ingestMessageThreads([ { id: THREAD_A, channel_id: PARENT, type: MessageTypes.DEFAULT, thread: thread(THREAD_A), } as unknown as Parameters[0][number], ]); expect(ChannelThreads.getThread(THREAD_A)?.parentId).toBe(PARENT); }); }); describe('ChannelThreads cascades', () => { beforeEach(() => { ThreadGuilds.handleGuild(guildObject([])); }); it('cleans up every thread of a deleted parent', () => { ChannelThreads.upsert(thread(THREAD_A, {member: MEMBER})); ChannelThreads.upsert(thread(THREAD_B)); ChannelThreads.handleParentDelete(PARENT); expect( vi .mocked(cleanupChannelLocalState) .mock.calls.map(([channel]) => channel.id) .sort(), ).toEqual([THREAD_A, THREAD_B]); expect(ChannelThreads.getThreadIdsForParent(PARENT)).toEqual([]); expect(ThreadMemberships.isMember(THREAD_A)).toBe(false); }); it('drops a deleted thread and its membership', () => { ChannelThreads.upsert(thread(THREAD_A, {member: MEMBER})); ChannelThreads.handleThreadDelete({id: THREAD_A, guild_id: GUILD, parent_id: PARENT, type: 11}); expect(ChannelThreads.hasThread(THREAD_A)).toBe(false); expect(ThreadMemberships.isMember(THREAD_A)).toBe(false); expect(channelStore.has(THREAD_A)).toBe(false); }); it('returns a full view of a deleted thread to its parent before the generic fallback', () => { ChannelThreads.upsert(thread(THREAD_A)); runInAction(() => { navigation.guildId = GUILD; navigation.channelId = THREAD_A; }); const replaceWith = vi.mocked(RouterUtils.replaceWith); const cleanup = vi.mocked(cleanupChannelLocalState); ChannelThreads.handleThreadDelete({id: THREAD_A, guild_id: GUILD, parent_id: PARENT, type: 11}); expect(replaceWith).toHaveBeenCalledWith(`/channels/${GUILD}/${PARENT}`); expect(replaceWith.mock.invocationCallOrder[0]).toBeLessThan(cleanup.mock.invocationCallOrder[0]); }); it('removes stale active threads only for the synced parents', () => { ChannelThreads.upsert(thread(THREAD_A)); ChannelThreads.upsert(thread(THREAD_B, {parent_id: OTHER_PARENT})); ChannelThreads.handleListSync( {guild_id: GUILD, channel_ids: [PARENT], threads: [], members: []}, new Set(), ); expect(ChannelThreads.hasThread(THREAD_A)).toBe(false); expect(ChannelThreads.hasThread(THREAD_B)).toBe(true); }); it('drops stale memberships on a guild resync but keeps the threads', () => { ChannelThreads.upsert(thread(THREAD_A, {member: MEMBER})); ChannelThreads.upsert(thread(THREAD_B, {member: MEMBER, thread_metadata: metadata({archived: true})})); ChannelThreads.ingestGuildThreads(GUILD, []); expect(ThreadMemberships.isMember(THREAD_A)).toBe(false); expect(ThreadMemberships.isMember(THREAD_B)).toBe(true); expect(ChannelThreads.hasThread(THREAD_A)).toBe(true); }); it('keeps the thread that is open in a view through a list sync', () => { ChannelThreads.upsert(thread(THREAD_A)); ChannelThreads.handleListSync({guild_id: GUILD, threads: [], members: []}, new Set([THREAD_A])); expect(ChannelThreads.hasThread(THREAD_A)).toBe(true); }); }); describe('ThreadGuilds flip', () => { it('activates from the threads key and purges on the kill', () => { ThreadGuilds.handleGuild(guildObject([thread(THREAD_A, {member: MEMBER})])); ChannelThreads.ingestGuildThreads(GUILD, [thread(THREAD_A, {member: MEMBER})]); expect(ThreadGuilds.isActive(GUILD)).toBe(true); runInAction(() => { navigation.guildId = GUILD; navigation.channelId = PARENT; navigation.threadId = THREAD_A; }); ThreadGuilds.handleGuild(guildObject()); expect(ThreadGuilds.isActive(GUILD)).toBe(false); const cleaned = vi.mocked(cleanupChannelLocalState).mock.calls.map(([channel]) => channel.id); expect(cleaned).toContain(THREAD_A); expect(cleaned).toContain(FORUM); expect(cleaned).not.toContain(PARENT); expect(ChannelThreads.getGuildThreadIds(GUILD)).toEqual([]); expect(ThreadMemberships.isMember(THREAD_A)).toBe(false); expect(vi.mocked(RouterUtils.replaceWith)).toHaveBeenCalledWith(`/channels/${GUILD}/${PARENT}`); expect(ThreadGuilds.isPurgedEvent({channel_id: THREAD_A})).toBe(true); expect(ThreadGuilds.isPurgedEvent({channel_id: PARENT})).toBe(false); expect(vi.mocked(http.delete)).not.toHaveBeenCalled(); ThreadGuilds.handleGuild(guildObject([])); expect(ThreadGuilds.isPurgedEvent({channel_id: THREAD_A})).toBe(false); }); it('routes a forum post panel to the guild root on the kill', () => { const post = thread(THREAD_B, {parent_id: FORUM, member: MEMBER}); ThreadGuilds.handleGuild(guildObject([post])); ChannelThreads.ingestGuildThreads(GUILD, [post]); runInAction(() => { navigation.guildId = GUILD; navigation.channelId = FORUM; navigation.threadId = THREAD_B; }); ThreadGuilds.handleGuild(guildObject()); expect(vi.mocked(cleanupChannelLocalState).mock.calls.map(([channel]) => channel.id)).toContain(FORUM); expect(vi.mocked(RouterUtils.replaceWith)).toHaveBeenLastCalledWith(`/channels/${GUILD}/`); }); it('routes a full view forum post to the guild root on the kill', () => { const post = thread(THREAD_B, {parent_id: FORUM, member: MEMBER}); ThreadGuilds.handleGuild(guildObject([post])); ChannelThreads.ingestGuildThreads(GUILD, [post]); runInAction(() => { navigation.guildId = GUILD; navigation.channelId = THREAD_B; }); ThreadGuilds.handleGuild(guildObject()); expect(vi.mocked(RouterUtils.replaceWith)).toHaveBeenLastCalledWith(`/channels/${GUILD}/`); }); it('routes a full view text thread to its parent on the kill', () => { const child = thread(THREAD_A, {member: MEMBER}); ThreadGuilds.handleGuild(guildObject([child])); ChannelThreads.ingestGuildThreads(GUILD, [child]); runInAction(() => { navigation.guildId = GUILD; navigation.channelId = THREAD_A; }); ThreadGuilds.handleGuild(guildObject()); expect(vi.mocked(RouterUtils.replaceWith)).toHaveBeenLastCalledWith(`/channels/${GUILD}/${PARENT}`); }); it('routes a thread view to the guild root when a new READY drops its forum', () => { runInAction(() => { navigation.guildId = GUILD; navigation.channelId = FORUM; navigation.threadId = THREAD_B; }); const forum = {id: FORUM, guild_id: GUILD, type: ChannelTypes.GUILD_FORUM}; ThreadGuilds.handleGatewayReady([{...guildObject(), channels: [forum]}]); expect(vi.mocked(RouterUtils.replaceWith)).toHaveBeenLastCalledWith(`/channels/${GUILD}/`); const parent = {id: PARENT, guild_id: GUILD, type: ChannelTypes.GUILD_TEXT}; runInAction(() => { navigation.channelId = PARENT; }); ThreadGuilds.handleGatewayReady([{...guildObject(), channels: [parent]}]); expect(vi.mocked(RouterUtils.replaceWith)).toHaveBeenLastCalledWith(`/channels/${GUILD}/${PARENT}`); }); it('purges a guild that is missing from a new READY', () => { ThreadGuilds.handleGuild(guildObject([thread(THREAD_A, {member: MEMBER})])); ChannelThreads.ingestGuildThreads(GUILD, [thread(THREAD_A, {member: MEMBER})]); ThreadGuilds.handleGatewayReady([]); expect(ThreadGuilds.isActive(GUILD)).toBe(false); const cleaned = vi.mocked(cleanupChannelLocalState).mock.calls.map(([channel]) => channel.id); expect(cleaned).toContain(FORUM); expect(ThreadGuilds.isPurgedEvent({channel_id: THREAD_A})).toBe(true); }); it('ignores unavailable guild stubs and deletes for the gate', () => { ThreadGuilds.handleGuild(guildObject([])); ThreadGuilds.handleGatewayReady([ {id: GUILD, unavailable: true} as unknown as Parameters[0], ]); ThreadGuilds.handleGuildDelete(GUILD, true); expect(ThreadGuilds.isActive(GUILD)).toBe(true); ThreadGuilds.handleGuildDelete(GUILD, false); expect(ThreadGuilds.isActive(GUILD)).toBe(false); }); }); describe('control arm', () => { it('stays inert for guilds without the threads key', async () => { ThreadGuilds.handleGatewayReady([guildObject()]); runInAction(() => { selectedGuild.selectedGuildId = GUILD; gateway.isReady = true; }); ThreadSubscriptions.handleConnectionReady(); await Promise.resolve(); expect(ThreadGuilds.anyActive).toBe(false); expect(ThreadGuilds.isPurgedEvent({channel_id: PARENT})).toBe(false); expect(updateGuildSubscriptions).not.toHaveBeenCalled(); expect(vi.mocked(http.get)).not.toHaveBeenCalled(); expect(vi.mocked(http.post)).not.toHaveBeenCalled(); ThreadGuilds.handleGuild(guildObject([])); expect(updateGuildSubscriptions).toHaveBeenCalledWith({ subscriptions: {[GUILD]: {threads: true, thread_member_lists: []}}, }); }); it('sends thread member lists only for subscribed threads of the active guild', () => { ThreadGuilds.handleGuild(guildObject([])); runInAction(() => { selectedGuild.selectedGuildId = GUILD; gateway.isReady = true; }); ThreadSubscriptions.handleConnectionReady(); const release = ThreadRoster.subscribe(GUILD, THREAD_A); expect(updateGuildSubscriptions).toHaveBeenLastCalledWith({ subscriptions: {[GUILD]: {threads: true, thread_member_lists: [THREAD_A]}}, }); release(); }); it('serializes a control channel without any new keys', () => { const wire = { id: PARENT, guild_id: GUILD, type: ChannelTypes.GUILD_TEXT, name: 'general', topic: null, position: 1, parent_id: null, last_message_id: null, permission_overwrites: [], nsfw: false, rate_limit_per_user: 0, }; const keys = Object.keys(new Channel(wire).toJSON()).sort(); expect(keys).toEqual( [ 'bitrate', 'content_warning_level', 'content_warning_text', 'guild_id', 'icon', 'id', 'last_message_id', 'last_pin_timestamp', 'name', 'nicks', 'nsfw', 'nsfw_override', 'owner_id', 'parent_id', 'permission_overwrites', 'position', 'rate_limit_per_user', 'recipients', 'rtc_region', 'topic', 'type', 'url', 'user_limit', 'voice_connection_limit', ].sort(), ); expect(new Channel(wire).threadFields).toBeNull(); }); it('refuses a thread for a guild that is not active', () => { expect(ChannelThreads.upsert(thread(THREAD_A, {member: MEMBER}))).toBeUndefined(); expect(channelStore.has(THREAD_A)).toBe(false); expect(ChannelThreads.hasThread(THREAD_A)).toBe(false); expect(ThreadMemberships.isMember(THREAD_A)).toBe(false); }); it('sends no thread keys on a new connection until READY was ingested', () => { ThreadGuilds.handleGuild(guildObject([])); runInAction(() => { selectedGuild.selectedGuildId = GUILD; gateway.isReady = true; }); expect(updateGuildSubscriptions).not.toHaveBeenCalled(); ThreadGuilds.handleGatewayReady([guildObject()]); ThreadSubscriptions.handleConnectionReady(); expect(updateGuildSubscriptions).not.toHaveBeenCalled(); }); it('refuses thread and forum ids for synced preferences only', () => { ThreadGuilds.handleGuild(guildObject([])); ChannelThreads.upsert(thread(THREAD_A)); expect(isSyncExcludedChannelId(THREAD_A)).toBe(true); expect(isSyncExcludedChannelId(FORUM)).toBe(true); expect(isSyncExcludedChannelId(PARENT)).toBe(false); expect(isSyncExcludedChannelId('1500000000000000999')).toBe(false); }); }); describe('control arm message grouping', () => { const SYSTEM_TYPES = [1, 2, 3, 4, 5, 6, 7, MessageTypes.THREAD_CREATED, MessageTypes.CLIENT_SYSTEM]; function fakeMessage(type: number, offsetMs: number) { return { type, author: {id: 'author', username: 'author'}, webhookId: undefined, timestamp: new Date(Date.UTC(2026, 8, 28, 12, 0, 0) + offsetMs), mentions: [], mentionRoles: [], mentionEveryone: false, hasFlag: () => false, isUserMessage: () => type === MessageTypes.DEFAULT || type === MessageTypes.REPLY || type === MessageTypes.CLIENT_SYSTEM, } as unknown as Parameters[2]; } it("keeps today's grouping for system, reply and client messages while inactive", () => { expect(ThreadGuilds.anyActive).toBe(false); for (const type of SYSTEM_TYPES) { expect(isNewMessageGroup(undefined, fakeMessage(MessageTypes.DEFAULT, 0), fakeMessage(type, 1000))).toBe(true); expect(isNewMessageGroup(undefined, fakeMessage(type, 0), fakeMessage(MessageTypes.DEFAULT, 1000))).toBe(true); expect(isNewMessageGroup(undefined, fakeMessage(type, 0), fakeMessage(type, 1000))).toBe(false); } expect( isNewMessageGroup(undefined, fakeMessage(MessageTypes.DEFAULT, 0), fakeMessage(MessageTypes.REPLY, 1000)), ).toBe(true); expect( isNewMessageGroup(undefined, fakeMessage(MessageTypes.DEFAULT, 0), fakeMessage(MessageTypes.DEFAULT, 1000)), ).toBe(false); }); });