From 34e4afa92bdbf0937cb1b04b79583d8d1b4cd4f1 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Mon, 20 Jul 2026 11:23:43 +0000 Subject: [PATCH] Add unit tests for discordAdapters.ts --- .jules/verifier.md | 5 + packages/bot/tests/discordAdapters.test.ts | 177 +++++++++++++++++++++ 2 files changed, 182 insertions(+) create mode 100644 packages/bot/tests/discordAdapters.test.ts diff --git a/.jules/verifier.md b/.jules/verifier.md index b208ac16..ced9dc24 100644 --- a/.jules/verifier.md +++ b/.jules/verifier.md @@ -23,3 +23,8 @@ **Challenge:** Testing `FirebaseService` logic without hitting a real Firestore instance or initializing `firebase-admin`, while properly chaining mocked references (like `db.collection().doc().get()`). **Solution:** Create a custom mock object structure (`createMockDbWithDocRef`) that mimics the exact shape of the Firestore types (e.g. `FirebaseDb`, `FirebaseCollection`, `FirebaseDocRef`) using `vi.fn()` for each level. Cast the mocked structure using `as unknown as FirebaseService['db']` to satisfy TypeScript when setting `service.db`. **Guideline:** When mocking chained external APIs like Firestore in TypeScript with Vitest, construct the entire chain of returns using mocked functions and inject the root mock directly into the service instance, avoiding actual constructor initialization. + +## 2026-07-20 - [Mocking Discord.js Collections in Vitest] +**Challenge:** Testing `adaptGuild`'s lazy evaluation required simulating a `discord.js` `Collection` (which extends `Map`), specifically its `.filter` and `.map` methods which return new Collections or Arrays, and `.get`. +**Solution:** Mocked the `channels.cache` object with standard functions that mimic the Collection logic, ensuring we cast it properly without throwing TypeScript errors (e.g. `mockCache as unknown as typeof mockDjsGuild.channels.cache` doesn't work well due to readonly properties, instead cast the parent: `(mockDjsGuild as { channels: { cache: unknown } }).channels.cache = mockCache;`). +**Guideline:** When mocking nested structures with strict types and readonly properties like Discord.js `Guild` -> `channels.cache`, bypass readonly constraints by casting the parent container to an object with an `unknown` typed child property before assignment. diff --git a/packages/bot/tests/discordAdapters.test.ts b/packages/bot/tests/discordAdapters.test.ts new file mode 100644 index 00000000..541ca448 --- /dev/null +++ b/packages/bot/tests/discordAdapters.test.ts @@ -0,0 +1,177 @@ +import { describe, it, expect, vi } from 'vitest'; +import { buildVoiceChannelsSnapshot, adaptGuild } from '../src/core/discordAdapters.js'; +import type { Guild as DjsGuild } from 'discord.js'; + +describe('discordAdapters', () => { + describe('buildVoiceChannelsSnapshot', () => { + it('computes correct user count, ignoring bots', () => { + const channels = [ + { + id: '1', + name: 'Channel 1', + members: [{ bot: false }, { bot: true }, { bot: false }] + } + ]; + + const result = buildVoiceChannelsSnapshot(channels); + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ + id: '1', + name: 'Channel 1', + userCount: 2 + }); + }); + + it('sorts channels correctly when options.sorted is true', () => { + const channels = [ + { + id: '1', + name: 'Z Channel', + members: [{ bot: false }] // 1 user + }, + { + id: '2', + name: 'A Channel', + members: [{ bot: false }, { bot: false }] // 2 users + }, + { + id: '3', + name: 'B Channel', + members: [{ bot: false }] // 1 user + } + ]; + + const result = buildVoiceChannelsSnapshot(channels, { sorted: true }); + + expect(result).toEqual([ + { id: '2', name: 'A Channel', userCount: 2 }, + { id: '3', name: 'B Channel', userCount: 1 }, + { id: '1', name: 'Z Channel', userCount: 1 }, + ]); + }); + }); + + describe('adaptGuild', () => { + it('returns null if djsGuild is null', () => { + expect(adaptGuild(null, vi.fn())).toBeNull(); + }); + + it('adapts a guild with primitive properties correctly', () => { + const mockDjsGuild = { + id: '123', + name: 'Test Guild', + iconURL: vi.fn().mockReturnValue('https://example.com/icon.png'), + channels: { cache: new Map() } + } as unknown as DjsGuild; + + const adapted = adaptGuild(mockDjsGuild, vi.fn()); + + expect(adapted).not.toBeNull(); + expect(adapted?.id).toBe('123'); + expect(adapted?.name).toBe('Test Guild'); + expect(adapted?.icon).toEqual({ url: 'https://example.com/icon.png' }); + }); + + it('adapts a guild without an icon correctly', () => { + const mockDjsGuild = { + id: '123', + name: 'Test Guild', + iconURL: vi.fn().mockReturnValue(null), + channels: { cache: new Map() } + } as unknown as DjsGuild; + + const adapted = adaptGuild(mockDjsGuild, vi.fn()); + + expect(adapted).not.toBeNull(); + expect(adapted?.icon).toBeNull(); + }); + + it('lazily evaluates voice_channels getter', () => { + const adaptMock = vi.fn().mockImplementation((ch) => ({ id: ch.id })); + + const channelCache = new Map(); + channelCache.set('c1', { id: 'c1', isVoiceBased: () => true }); + channelCache.set('c2', { id: 'c2', isVoiceBased: () => false }); + channelCache.set('c3', { id: 'c3', isVoiceBased: () => true }); + + const mockDjsGuild = { + id: '123', + name: 'Test Guild', + iconURL: vi.fn().mockReturnValue(null), + channels: { + cache: { + filter: (predicate: (v: unknown) => boolean) => { + const res = new Map(); + for (const [k, v] of channelCache.entries()) { + if (predicate(v)) res.set(k, v); + } + return res; + }, + get: (id: string) => channelCache.get(id) + } + } + } as unknown as DjsGuild; + + // Ensure cache logic behaves somewhat like Discord Collection + const mockCache: unknown = { + filter: function(predicate: (v: unknown) => boolean) { + const res = new Map(); + for (const [k, v] of channelCache.entries()) { + if (predicate(v)) res.set(k, v); + } + return { + map: function(mapper: (v: unknown) => unknown) { + const out = []; + for (const v of res.values()) { + out.push(mapper(v)); + } + return out; + } + }; + }, + get: (id: string) => channelCache.get(id) + }; + + (mockDjsGuild as { channels: { cache: unknown } }).channels.cache = mockCache; + + const adapted = adaptGuild(mockDjsGuild, adaptMock); + + expect(adaptMock).not.toHaveBeenCalled(); // Lazy evaluation + + const voiceChannels = adapted?.voice_channels; + + expect(voiceChannels).toHaveLength(2); + expect(voiceChannels).toEqual([{ id: 'c1' }, { id: 'c3' }]); + expect(adaptMock).toHaveBeenCalledTimes(2); + }); + + it('retrieves and adapts a specific voice channel by id', () => { + const adaptMock = vi.fn().mockImplementation((ch) => ({ id: ch.id })); + + const channelCache = new Map(); + channelCache.set('c1', { id: 'c1', isVoiceBased: () => true }); + channelCache.set('c2', { id: 'c2', isVoiceBased: () => false }); + + const mockDjsGuild = { + id: '123', + name: 'Test Guild', + iconURL: vi.fn().mockReturnValue(null), + channels: { + cache: channelCache + } + } as unknown as DjsGuild; + + const adapted = adaptGuild(mockDjsGuild, adaptMock); + + const vc = adapted?.get_channel('c1'); + expect(vc).toEqual({ id: 'c1' }); + expect(adaptMock).toHaveBeenCalledTimes(1); + + const textChannel = adapted?.get_channel('c2'); + expect(textChannel).toBeNull(); + + const nonExistent = adapted?.get_channel('c99'); + expect(nonExistent).toBeNull(); + }); + }); +});