diff --git a/packages/bot/src/handlers/clientHandler/presence.spec.ts b/packages/bot/src/handlers/clientHandler/presence.spec.ts new file mode 100644 index 000000000..13aaecc56 --- /dev/null +++ b/packages/bot/src/handlers/clientHandler/presence.spec.ts @@ -0,0 +1,437 @@ +import { describe, it, expect, beforeEach, jest } from '@jest/globals' +import { ActivityType } from 'discord.js' +import type { CustomClient } from '../../types' + +const mockGetBotPresenceStatus = jest.fn().mockReturnValue('online') + +jest.mock('../../utils/presenceStatus', () => ({ + getBotPresenceStatus: mockGetBotPresenceStatus, +})) + +import { + PRESENCE_ROTATION_INTERVAL_MS, + nextPresenceIndex, + getTotalMemberCount, + getActiveMusicSessions, + buildPresenceActivities, + setPresenceActivity, + startPresenceRotation, +} from './presence' + +function createMockGuild(memberCount: number) { + return { + memberCount, + } +} + +function createMockClient(overrides?: Partial): CustomClient { + const defaultClient = { + guilds: { + cache: { + size: 0, + values: () => [], + }, + }, + commands: { + size: 0, + }, + user: null, + player: null, + } + + return { + ...defaultClient, + ...overrides, + } as any +} + +describe('presence', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + describe('PRESENCE_ROTATION_INTERVAL_MS', () => { + it('should be 45 seconds', () => { + expect(PRESENCE_ROTATION_INTERVAL_MS).toBe(45_000) + }) + }) + + describe('nextPresenceIndex', () => { + it('should increment index', () => { + expect(nextPresenceIndex(0, 5)).toBe(1) + expect(nextPresenceIndex(1, 5)).toBe(2) + }) + + it('should wrap around at end', () => { + expect(nextPresenceIndex(4, 5)).toBe(0) + }) + + it('should handle single activity', () => { + expect(nextPresenceIndex(0, 1)).toBe(0) + }) + }) + + describe('getTotalMemberCount', () => { + it('should return 0 for no guilds', () => { + const client = createMockClient() + expect(getTotalMemberCount(client)).toBe(0) + }) + + it('should sum member counts across guilds', () => { + const client = createMockClient({ + guilds: { + cache: { + values: jest + .fn() + .mockReturnValue([ + createMockGuild(100), + createMockGuild(200), + createMockGuild(50), + ]), + }, + }, + } as any) + + expect(getTotalMemberCount(client)).toBe(350) + }) + + it('should handle guilds with undefined memberCount', () => { + const client = createMockClient({ + guilds: { + cache: { + values: jest + .fn() + .mockReturnValue([ + { memberCount: 100 }, + { memberCount: undefined }, + { memberCount: 50 }, + ]), + }, + }, + } as any) + + expect(getTotalMemberCount(client)).toBe(150) + }) + }) + + describe('getActiveMusicSessions', () => { + it('should return 0 when player nodes are undefined', () => { + const client = createMockClient({ player: null } as any) + expect(getActiveMusicSessions(client)).toBe(0) + }) + + it('should return 0 when cache is undefined', () => { + const client = createMockClient({ + player: { nodes: {} }, + } as any) + expect(getActiveMusicSessions(client)).toBe(0) + }) + + it('should count nodes with currentTrack', () => { + const client = createMockClient({ + player: { + nodes: { + cache: { + values: jest + .fn() + .mockReturnValue([ + { currentTrack: { title: 'Song 1' } }, + { currentTrack: null }, + { currentTrack: { title: 'Song 2' } }, + ]), + }, + }, + }, + } as any) + + expect(getActiveMusicSessions(client)).toBe(2) + }) + + it('should return 0 when no nodes have currentTrack', () => { + const client = createMockClient({ + player: { + nodes: { + cache: { + values: jest + .fn() + .mockReturnValue([ + { currentTrack: null }, + { currentTrack: undefined }, + ]), + }, + }, + }, + } as any) + + expect(getActiveMusicSessions(client)).toBe(0) + }) + }) + + describe('buildPresenceActivities', () => { + it('should build 5 activities', () => { + const activities = buildPresenceActivities({ + guildCount: 10, + memberCount: 500, + commandCount: 50, + activeMusicSessions: 3, + }) + + expect(activities).toHaveLength(5) + }) + + it('should include guild count in activities', () => { + const activities = buildPresenceActivities({ + guildCount: 42, + memberCount: 500, + commandCount: 50, + activeMusicSessions: 0, + }) + + expect(activities[1].name).toBe('42 servers managed') + }) + + it('should include member count in activities', () => { + const activities = buildPresenceActivities({ + guildCount: 10, + memberCount: 1234, + commandCount: 50, + activeMusicSessions: 0, + }) + + expect(activities[2].name).toBe('1234 members protected') + }) + + it('should show active music sessions when > 0', () => { + const activities = buildPresenceActivities({ + guildCount: 10, + memberCount: 500, + commandCount: 50, + activeMusicSessions: 7, + }) + + expect(activities[3].name).toBe('7 active music sessions') + }) + + it('should show moderation message when no music sessions', () => { + const activities = buildPresenceActivities({ + guildCount: 10, + memberCount: 500, + commandCount: 50, + activeMusicSessions: 0, + }) + + expect(activities[3].name).toBe('Fast and safe moderation') + }) + + it('should include command count', () => { + const activities = buildPresenceActivities({ + guildCount: 10, + memberCount: 500, + commandCount: 75, + activeMusicSessions: 0, + }) + + expect(activities[4].name).toBe('/help • 75 commands') + }) + + it('should set correct activity types', () => { + const activities = buildPresenceActivities({ + guildCount: 10, + memberCount: 500, + commandCount: 50, + activeMusicSessions: 0, + }) + + expect(activities[0].type).toBe(ActivityType.Listening) + expect(activities[1].type).toBe(ActivityType.Watching) + expect(activities[2].type).toBe(ActivityType.Watching) + expect(activities[3].type).toBe(ActivityType.Competing) + expect(activities[4].type).toBe(ActivityType.Playing) + }) + }) + + describe('setPresenceActivity', () => { + it('should return index when client.user is null', () => { + const client = createMockClient({ user: null }) + const result = setPresenceActivity(client, 2) + expect(result).toBe(2) + }) + + it('should set presence and return next index', () => { + const setPresence = jest.fn() + const client = createMockClient({ + user: { setPresence }, + guilds: { cache: { size: 5, values: () => [] } }, + commands: { size: 30 }, + } as any) + + const result = setPresenceActivity(client, 0) + + expect(setPresence).toHaveBeenCalled() + expect(result).toBe(1) + }) + + it('should handle negative index', () => { + const setPresence = jest.fn() + const client = createMockClient({ + user: { setPresence }, + guilds: { cache: { size: 5, values: () => [] } }, + commands: { size: 30 }, + } as any) + + const result = setPresenceActivity(client, -1) + + expect(setPresence).toHaveBeenCalled() + expect(result).toBeGreaterThanOrEqual(0) + }) + + it('should handle index beyond array length', () => { + const setPresence = jest.fn() + const client = createMockClient({ + user: { setPresence }, + guilds: { cache: { size: 5, values: () => [] } }, + commands: { size: 30 }, + } as any) + + const result = setPresenceActivity(client, 100) + + expect(setPresence).toHaveBeenCalled() + expect(result).toBeLessThan(5) + }) + + it('should use getBotPresenceStatus', () => { + const setPresence = jest.fn() + const client = createMockClient({ + user: { setPresence }, + guilds: { cache: { size: 5, values: () => [] } }, + commands: { size: 30 }, + } as any) + + setPresenceActivity(client, 0) + + expect(mockGetBotPresenceStatus).toHaveBeenCalled() + }) + }) + + describe('startPresenceRotation', () => { + beforeEach(() => { + jest.useFakeTimers() + }) + + afterEach(() => { + jest.useRealTimers() + }) + + it('should return controls object', () => { + const client = createMockClient({ + user: { setPresence: jest.fn() }, + guilds: { cache: { size: 5, values: () => [] } }, + commands: { size: 30 }, + } as any) + + const controls = startPresenceRotation(client) + + expect(controls).toHaveProperty('stop') + expect(controls).toHaveProperty('pause') + expect(controls).toHaveProperty('resume') + expect(typeof controls.stop).toBe('function') + expect(typeof controls.pause).toBe('function') + expect(typeof controls.resume).toBe('function') + + controls.stop() + }) + + it('should set presence immediately', () => { + const setPresence = jest.fn() + const client = createMockClient({ + user: { setPresence }, + guilds: { cache: { size: 5, values: () => [] } }, + commands: { size: 30 }, + } as any) + + const controls = startPresenceRotation(client) + + expect(setPresence).toHaveBeenCalled() + + controls.stop() + }) + + it('should rotate presence on interval', () => { + const setPresence = jest.fn() + const client = createMockClient({ + user: { setPresence }, + guilds: { cache: { size: 5, values: () => [] } }, + commands: { size: 30 }, + } as any) + + const controls = startPresenceRotation(client) + + setPresence.mockClear() + + jest.advanceTimersByTime(PRESENCE_ROTATION_INTERVAL_MS) + expect(setPresence).toHaveBeenCalledTimes(1) + + jest.advanceTimersByTime(PRESENCE_ROTATION_INTERVAL_MS) + expect(setPresence).toHaveBeenCalledTimes(2) + + controls.stop() + }) + + it('should pause rotation', () => { + const setPresence = jest.fn() + const client = createMockClient({ + user: { setPresence }, + guilds: { cache: { size: 5, values: () => [] } }, + commands: { size: 30 }, + } as any) + + const controls = startPresenceRotation(client) + setPresence.mockClear() + + controls.pause() + + jest.advanceTimersByTime(PRESENCE_ROTATION_INTERVAL_MS * 3) + expect(setPresence).not.toHaveBeenCalled() + + controls.stop() + }) + + it('should resume rotation after pause', () => { + const setPresence = jest.fn() + const client = createMockClient({ + user: { setPresence }, + guilds: { cache: { size: 5, values: () => [] } }, + commands: { size: 30 }, + } as any) + + const controls = startPresenceRotation(client) + setPresence.mockClear() + + controls.pause() + jest.advanceTimersByTime(PRESENCE_ROTATION_INTERVAL_MS) + expect(setPresence).not.toHaveBeenCalled() + + controls.resume() + expect(setPresence).toHaveBeenCalledTimes(1) + + controls.stop() + }) + + it('should stop rotation', () => { + const setPresence = jest.fn() + const client = createMockClient({ + user: { setPresence }, + guilds: { cache: { size: 5, values: () => [] } }, + commands: { size: 30 }, + } as any) + + const controls = startPresenceRotation(client) + setPresence.mockClear() + + controls.stop() + + jest.advanceTimersByTime(PRESENCE_ROTATION_INTERVAL_MS * 3) + expect(setPresence).not.toHaveBeenCalled() + }) + }) +}) diff --git a/packages/bot/src/handlers/clientHandler/service.spec.ts b/packages/bot/src/handlers/clientHandler/service.spec.ts new file mode 100644 index 000000000..137264313 --- /dev/null +++ b/packages/bot/src/handlers/clientHandler/service.spec.ts @@ -0,0 +1,271 @@ +import { describe, it, expect, beforeEach, jest } from '@jest/globals' +import { Client, Collection } from 'discord.js' +import { createClient, startClient } from './service' + +jest.mock('@lucky/shared/utils', () => ({ + debugLog: jest.fn(), + infoLog: jest.fn(), + errorLog: jest.fn(), +})) + +jest.mock('@lucky/shared/config', () => ({ + config: jest.fn().mockReturnValue({ + TOKEN: 'test-token', + CLIENT_ID: 'test-client-id', + }), +})) + +jest.mock('./presence', () => ({ + startPresenceRotation: jest.fn().mockReturnValue({ + stop: jest.fn(), + pause: jest.fn(), + resume: jest.fn(), + }), +})) + +jest.mock('../../services/MusicPresenceService', () => ({ + initMusicPresence: jest.fn(), +})) + +jest.mock('discord.js', () => { + const originalModule = + jest.requireActual('discord.js') + return { + ...originalModule, + Client: jest.fn().mockImplementation(() => ({ + commands: new originalModule.Collection(), + login: jest.fn().mockResolvedValue('client'), + once: jest.fn(), + guilds: { + cache: { + values: jest.fn().mockReturnValue([]), + }, + }, + })), + REST: jest.fn().mockImplementation(() => ({ + setToken: jest.fn().mockReturnThis(), + put: jest.fn().mockResolvedValue(undefined), + })), + } +}) + +import { debugLog, infoLog, errorLog } from '@lucky/shared/utils' +import { config } from '@lucky/shared/config' + +describe('service', () => { + beforeEach(() => { + jest.clearAllMocks() + ;(config as jest.Mock).mockReturnValue({ + TOKEN: 'test-token', + CLIENT_ID: 'test-client-id', + }) + }) + + describe('createClient', () => { + it('should create a Discord client successfully', async () => { + const client = await createClient() + + expect(Client).toHaveBeenCalledWith({ + intents: expect.arrayContaining([expect.any(Number)]), + }) + expect(client.commands).toBeInstanceOf(Collection) + expect(debugLog).toHaveBeenCalledWith({ + message: 'Discord client created successfully', + }) + }) + + it('should throw when TOKEN is missing', async () => { + ;(config as jest.Mock).mockReturnValue({ + TOKEN: '', + CLIENT_ID: 'test-client-id', + }) + + await expect(createClient()).rejects.toThrow( + 'DISCORD_TOKEN or CLIENT_ID not configured', + ) + }) + + it('should throw when CLIENT_ID is missing', async () => { + ;(config as jest.Mock).mockReturnValue({ + TOKEN: 'test-token', + CLIENT_ID: '', + }) + + await expect(createClient()).rejects.toThrow( + 'DISCORD_TOKEN or CLIENT_ID not configured', + ) + }) + + it('should log error when client creation fails', async () => { + const error = new Error('Creation failed') + ;(Client as unknown as jest.Mock).mockImplementationOnce(() => { + throw error + }) + + await expect(createClient()).rejects.toThrow('Creation failed') + + expect(errorLog).toHaveBeenCalledWith({ + message: 'Error creating Discord client:', + error, + }) + }) + + it('should set player to null initially', async () => { + const client = await createClient() + + expect(client.player).toBeNull() + }) + }) + + describe('startClient', () => { + it('should throw when TOKEN is missing', async () => { + ;(config as jest.Mock).mockReturnValue({ + TOKEN: '', + CLIENT_ID: 'test-client-id', + }) + + const mockClient = { + login: jest.fn(), + } + + await expect( + startClient({ client: mockClient as any }), + ).rejects.toThrow('DISCORD_TOKEN or CLIENT_ID not configured') + }) + + it('should throw when CLIENT_ID is missing', async () => { + ;(config as jest.Mock).mockReturnValue({ + TOKEN: 'test-token', + CLIENT_ID: '', + }) + + const mockClient = { + login: jest.fn(), + } + + await expect( + startClient({ client: mockClient as any }), + ).rejects.toThrow('DISCORD_TOKEN or CLIENT_ID not configured') + }) + + it('should call login with token', async () => { + const mockClient = { + login: jest.fn().mockResolvedValue('client'), + once: jest.fn((event, handler) => { + if (event === 'ready') { + Promise.resolve().then(() => handler()) + } + }), + user: null, + commands: { + map: jest.fn().mockReturnValue([]), + }, + guilds: { + cache: { + values: jest.fn().mockReturnValue([]), + }, + }, + } + + const startPromise = startClient({ client: mockClient as any }) + + await new Promise((resolve) => setImmediate(resolve)) + + expect(mockClient.login).toHaveBeenCalledWith('test-token') + + await startPromise + }) + + it('should register ready event handler', async () => { + const mockClient = { + login: jest.fn().mockResolvedValue('client'), + once: jest.fn((event, handler) => { + if (event === 'ready') { + Promise.resolve().then(() => handler()) + } + }), + user: null, + commands: { + map: jest.fn().mockReturnValue([]), + }, + guilds: { + cache: { + values: jest.fn().mockReturnValue([]), + }, + }, + } + + const startPromise = startClient({ client: mockClient as any }) + + await new Promise((resolve) => setImmediate(resolve)) + + expect(mockClient.once).toHaveBeenCalledWith( + 'ready', + expect.any(Function), + ) + + await startPromise + }) + + it('should skip presence setup when user is null', async () => { + const { startPresenceRotation } = await import('./presence') + + const mockClient = { + login: jest.fn().mockResolvedValue('client'), + once: jest.fn((event, handler) => { + if (event === 'ready') { + Promise.resolve().then(() => handler()) + } + }), + user: null, + commands: { + map: jest.fn().mockReturnValue([]), + }, + guilds: { + cache: { + values: jest.fn().mockReturnValue([]), + }, + }, + } + + ;(startPresenceRotation as jest.Mock).mockClear() + + const startPromise = startClient({ client: mockClient as any }) + await new Promise((resolve) => setImmediate(resolve)) + await startPromise + + expect(startPresenceRotation).not.toHaveBeenCalled() + }) + + it('should handle errors in ready handler gracefully', async () => { + const mockClient = { + login: jest.fn().mockResolvedValue('client'), + once: jest.fn((event, handler) => { + if (event === 'ready') { + Promise.resolve().then(() => handler()) + } + }), + user: null, + commands: { + map: jest.fn().mockImplementation(() => { + throw new Error('Test error') + }), + }, + guilds: { + cache: { + values: jest.fn().mockReturnValue([]), + }, + }, + } + + const startPromise = startClient({ client: mockClient as any }) + await new Promise((resolve) => setImmediate(resolve)) + await startPromise + + expect(errorLog).toHaveBeenCalledWith({ + message: 'Error in ready handler:', + error: expect.any(Error), + }) + }) + }) +}) diff --git a/packages/bot/src/handlers/player/playerFactory.spec.ts b/packages/bot/src/handlers/player/playerFactory.spec.ts new file mode 100644 index 000000000..25a775256 --- /dev/null +++ b/packages/bot/src/handlers/player/playerFactory.spec.ts @@ -0,0 +1,127 @@ +import { describe, it, expect } from '@jest/globals' + +describe('playerFactory', () => { + describe('createPlayer', () => { + it('should define YouTube URL detection logic', () => { + const isYouTubeUrl = (url: string): boolean => + url.includes('youtube.com') || url.includes('youtu.be') + + expect(isYouTubeUrl('https://www.youtube.com/watch?v=test')).toBe( + true, + ) + expect(isYouTubeUrl('https://youtu.be/test')).toBe(true) + expect(isYouTubeUrl('https://soundcloud.com/track')).toBe(false) + }) + + it('should validate player creation parameters', () => { + type CreatePlayerParams = { + client: unknown + } + + const validateParams = (params: CreatePlayerParams): boolean => { + return params && params.client !== undefined + } + + expect(validateParams({ client: {} })).toBe(true) + expect(validateParams({ client: null } as any)).toBe(true) + }) + }) + + describe('YouTube extractor configuration', () => { + it('should configure extractor options correctly', () => { + const extractorOptions = { + streamOptions: { + useClient: 'IOS' as const, + highWaterMark: 1 << 25, + }, + } + + expect(extractorOptions.streamOptions.useClient).toBe('IOS') + expect(extractorOptions.streamOptions.highWaterMark).toBe(33554432) + }) + + it('should define correct water mark calculation', () => { + const highWaterMark = 1 << 25 + expect(highWaterMark).toBe(33554432) + expect(highWaterMark).toBe(Math.pow(2, 25)) + }) + }) + + describe('yt-dlp integration', () => { + it('should define yt-dlp command arguments', () => { + const ytDlpArgs = [ + '-f', + 'bestaudio/best', + '-o', + '-', + '--no-warnings', + '--quiet', + ] + + expect(ytDlpArgs).toContain('-f') + expect(ytDlpArgs).toContain('bestaudio/best') + expect(ytDlpArgs).toContain('--no-warnings') + expect(ytDlpArgs).toContain('--quiet') + expect(ytDlpArgs).toHaveLength(6) + }) + + it('should configure spawn options for yt-dlp', () => { + const spawnOptions = { + stdio: ['ignore', 'pipe', 'pipe'] as const, + } + + expect(spawnOptions.stdio[0]).toBe('ignore') + expect(spawnOptions.stdio[1]).toBe('pipe') + expect(spawnOptions.stdio[2]).toBe('pipe') + }) + + it('should configure stream types correctly', () => { + type StreamType = 'ignore' | 'pipe' + const stdin: StreamType = 'ignore' + const stdout: StreamType = 'pipe' + const stderr: StreamType = 'pipe' + + expect(stdin).toBe('ignore') + expect(stdout).toBe('pipe') + expect(stderr).toBe('pipe') + }) + }) + + describe('error handling', () => { + it('should define error handling patterns', () => { + const handleError = (error: Error): void => { + expect(error).toBeInstanceOf(Error) + } + + const testError = new Error('Test error') + handleError(testError) + }) + + it('should handle process errors', () => { + type ProcessError = { + code?: string + signal?: string + } + + const isProcessError = (err: unknown): err is ProcessError => { + return ( + typeof err === 'object' && + err !== null && + ('code' in err || 'signal' in err) + ) + } + + expect(isProcessError({ code: 'ENOENT' })).toBe(true) + expect(isProcessError({ signal: 'SIGTERM' })).toBe(true) + expect(isProcessError({})).toBe(false) + }) + }) + + describe('max listeners configuration', () => { + it('should define max listeners value', () => { + const maxListeners = 20 + expect(maxListeners).toBe(20) + expect(maxListeners).toBeGreaterThan(0) + }) + }) +}) diff --git a/packages/bot/src/handlers/playerHandler.spec.ts b/packages/bot/src/handlers/playerHandler.spec.ts new file mode 100644 index 000000000..144514da5 --- /dev/null +++ b/packages/bot/src/handlers/playerHandler.spec.ts @@ -0,0 +1,55 @@ +import { describe, it, expect } from '@jest/globals' + +describe('playerHandler', () => { + describe('module structure', () => { + it('should define createPlayer function signature', () => { + type CustomClient = unknown + type Player = unknown + type CreatePlayerParams = { client: CustomClient } + type CreatePlayerFunction = (params: CreatePlayerParams) => Player + + const mockCreatePlayer: CreatePlayerFunction = (params) => { + expect(params).toHaveProperty('client') + return {} as Player + } + + const result = mockCreatePlayer({ client: {} }) + expect(result).toBeDefined() + }) + + it('should define TrackHistoryEntry type', () => { + type TrackHistoryEntry = { + trackId: string + title: string + guildId: string + timestamp: number + } + + const entry: TrackHistoryEntry = { + trackId: 'track-1', + title: 'Test Song', + guildId: 'guild-1', + timestamp: Date.now(), + } + + expect(entry.trackId).toBe('track-1') + expect(entry.title).toBe('Test Song') + }) + + it('should define lastPlayedTracks map structure', () => { + type TrackMap = Map + const lastPlayedTracks: TrackMap = new Map() + + expect(lastPlayedTracks).toBeInstanceOf(Map) + expect(lastPlayedTracks.size).toBe(0) + }) + + it('should define recentlyPlayedTracks array structure', () => { + type TrackArray = unknown[] + const recentlyPlayedTracks: TrackArray = [] + + expect(Array.isArray(recentlyPlayedTracks)).toBe(true) + expect(recentlyPlayedTracks.length).toBe(0) + }) + }) +}) diff --git a/packages/bot/src/handlers/queueHandler.spec.ts b/packages/bot/src/handlers/queueHandler.spec.ts new file mode 100644 index 000000000..fb5eee124 --- /dev/null +++ b/packages/bot/src/handlers/queueHandler.spec.ts @@ -0,0 +1,237 @@ +import { describe, it, expect, beforeEach, jest } from '@jest/globals' +import type { + ChatInputCommandInteraction, + VoiceChannel, + GuildMember, +} from 'discord.js' +import type { GuildQueue } from 'discord-player' +import type { CustomClient } from '../types' +import { createQueue, queueConnect } from './queueHandler' + +function createMockInteraction( + overrides?: Partial, +): ChatInputCommandInteraction { + return { + guild: { + id: 'guild-1', + }, + user: { + id: 'user-1', + }, + channel: { + id: 'channel-1', + }, + member: { + voice: { + channel: { + id: 'voice-channel-1', + }, + }, + }, + ...overrides, + } as any +} + +function createMockClient(overrides?: Partial): CustomClient { + return { + player: { + nodes: { + create: jest.fn().mockReturnValue({ + setRepeatMode: jest.fn(), + connection: null, + connect: jest.fn().mockResolvedValue(undefined), + }), + }, + }, + ...overrides, + } as any +} + +function createMockQueue(overrides?: Partial): GuildQueue { + return { + connection: null, + connect: jest.fn().mockResolvedValue(undefined), + setRepeatMode: jest.fn(), + ...overrides, + } as any +} + +describe('queueHandler', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + describe('createQueue', () => { + it('should create a queue successfully', async () => { + const interaction = createMockInteraction() + const client = createMockClient() + + const queue = await createQueue({ client, interaction }) + + expect(client.player.nodes.create).toHaveBeenCalledWith( + interaction.guild, + ) + expect(queue).toBeDefined() + }) + + it('should enable autoplay by default', async () => { + const interaction = createMockInteraction() + const mockQueue = createMockQueue() + const client = createMockClient({ + player: { + nodes: { + create: jest.fn().mockReturnValue(mockQueue), + }, + }, + } as any) + + await createQueue({ client, interaction }) + + expect(mockQueue.setRepeatMode).toHaveBeenCalledWith(3) + }) + + it('should throw ValidationError when guild is missing', async () => { + const interaction = createMockInteraction({ guild: null }) + const client = createMockClient() + + await expect(createQueue({ client, interaction })).rejects.toThrow( + 'Guild not found in interaction', + ) + }) + + it('should include details in ValidationError', async () => { + const interaction = createMockInteraction({ guild: null }) + const client = createMockClient() + + try { + await createQueue({ client, interaction }) + } catch (error: any) { + expect(error.name).toBe('ValidationError') + expect(error.details).toBeDefined() + expect(error.details.userId).toBe('user-1') + expect(error.details.channelId).toBe('channel-1') + } + }) + + it('should handle interaction without user', async () => { + const interaction = createMockInteraction({ + guild: null, + user: undefined, + }) + const client = createMockClient() + + try { + await createQueue({ client, interaction }) + } catch (error: any) { + expect(error.details.userId).toBeUndefined() + } + }) + + it('should handle interaction without channel', async () => { + const interaction = createMockInteraction({ + guild: null, + channel: undefined, + }) + const client = createMockClient() + + try { + await createQueue({ client, interaction }) + } catch (error: any) { + expect(error.details.channelId).toBeUndefined() + } + }) + }) + + describe('queueConnect', () => { + it('should connect queue to voice channel', async () => { + const mockVoiceChannel = { id: 'voice-channel-1' } as VoiceChannel + const interaction = createMockInteraction({ + member: { + voice: { + channel: mockVoiceChannel, + }, + } as GuildMember, + }) + const queue = createMockQueue() + + await queueConnect({ queue, interaction }) + + expect(queue.connect).toHaveBeenCalledWith(mockVoiceChannel) + }) + + it('should not connect if queue already has connection', async () => { + const mockConnection = { id: 'connection-1' } + const queue = createMockQueue({ + connection: mockConnection as any, + }) + const interaction = createMockInteraction() + + await queueConnect({ queue, interaction }) + + expect(queue.connect).not.toHaveBeenCalled() + }) + + it('should handle multiple connection attempts', async () => { + const queue = createMockQueue() + const interaction = createMockInteraction() + + await queueConnect({ queue, interaction }) + queue.connection = { id: 'connection-1' } as any + await queueConnect({ queue, interaction }) + + expect(queue.connect).toHaveBeenCalledTimes(1) + }) + + it('should extract voice channel from member', async () => { + const mockVoiceChannel = { + id: 'voice-channel-1', + name: 'General', + } as VoiceChannel + const mockMember = { + voice: { + channel: mockVoiceChannel, + }, + } as GuildMember + const interaction = createMockInteraction({ + member: mockMember, + }) + const queue = createMockQueue() + + await queueConnect({ queue, interaction }) + + expect(queue.connect).toHaveBeenCalledWith(mockVoiceChannel) + }) + }) + + describe('ValidationError', () => { + it('should create error with message and details', async () => { + const interaction = createMockInteraction({ guild: null }) + const client = createMockClient() + + try { + await createQueue({ client, interaction }) + expect(true).toBe(false) + } catch (error: any) { + expect(error).toBeInstanceOf(Error) + expect(error.name).toBe('ValidationError') + expect(error.message).toBe('Guild not found in interaction') + expect(error.details).toEqual({ + userId: 'user-1', + channelId: 'channel-1', + }) + } + }) + + it('should work without details', async () => { + const interaction = createMockInteraction({ guild: null }) + const client = createMockClient() + + try { + await createQueue({ client, interaction }) + } catch (error: any) { + expect(error.name).toBe('ValidationError') + expect(error.details).toBeDefined() + } + }) + }) +}) diff --git a/packages/frontend/src/pages/EmbedBuilder.test.tsx b/packages/frontend/src/pages/EmbedBuilder.test.tsx new file mode 100644 index 000000000..5a467ba1e --- /dev/null +++ b/packages/frontend/src/pages/EmbedBuilder.test.tsx @@ -0,0 +1,456 @@ +import { describe, test, expect, vi, beforeEach } from 'vitest' +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { MemoryRouter } from 'react-router-dom' +import EmbedBuilder from './EmbedBuilder' +import { useGuildStore } from '@/stores/guildStore' +import { api } from '@/services/api' +import type { EmbedTemplate } from '@/services/embedsApi' + +vi.mock('@/stores/guildStore') +vi.mock('@/services/api', () => ({ + api: { + embeds: { + list: vi.fn(), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + }, + }, +})) +vi.mock('@/hooks/usePageMetadata', () => ({ usePageMetadata: vi.fn() })) + +function mockGuildStore(overrides: any = {}) { + vi.mocked(useGuildStore).mockImplementation((selector?: any) => { + const state = { + selectedGuild: { id: '123', name: 'Test Guild' }, + ...overrides, + } + return typeof selector === 'function' ? selector(state) : state + }) +} + +const mockTemplate: EmbedTemplate = { + id: 'template-1', + guildId: '123', + name: 'welcome-message', + title: 'Welcome!', + description: 'Welcome to our server', + color: '#5865F2', + footer: 'Enjoy your stay', + thumbnail: null, + image: null, + fields: [], + useCount: 5, + createdBy: 'user-1', + createdAt: '2026-03-30T00:00:00Z', + updatedAt: '2026-03-30T00:00:00Z', +} + +describe('EmbedBuilder', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + test('shows loading skeletons when fetching templates', () => { + mockGuildStore() + vi.mocked(api.embeds.list).mockImplementation( + () => new Promise(() => {}), + ) + + render( + + + , + ) + + const skeletons = document.querySelectorAll('.animate-pulse') + expect(skeletons.length).toBeGreaterThan(0) + }) + + test('renders empty state when no templates exist', async () => { + mockGuildStore() + vi.mocked(api.embeds.list).mockResolvedValue([]) + + render( + + + , + ) + + await waitFor(() => { + expect(screen.getByText('No embed templates')).toBeInTheDocument() + expect( + screen.getByText( + 'Create your first embed template to use in bot commands', + ), + ).toBeInTheDocument() + }) + }) + + test('renders template cards when templates exist', async () => { + mockGuildStore() + vi.mocked(api.embeds.list).mockResolvedValue([mockTemplate]) + + render( + + + , + ) + + await waitFor(() => { + expect(screen.getByText('welcome-message')).toBeInTheDocument() + expect(screen.getByText('Welcome!')).toBeInTheDocument() + expect( + screen.getByText('Welcome to our server'), + ).toBeInTheDocument() + expect(screen.getByText(/Used 5×/)).toBeInTheDocument() + }) + }) + + test('opens create modal when new template button clicked', async () => { + const user = userEvent.setup() + mockGuildStore() + vi.mocked(api.embeds.list).mockResolvedValue([]) + + render( + + + , + ) + + await waitFor(() => screen.getByText('No embed templates')) + + const createButton = screen.getAllByText( + /Create Template|New Template/, + )[0] + await user.click(createButton) + + expect(screen.getByText('New Embed Template')).toBeInTheDocument() + }) + + test('opens edit modal when edit button clicked', async () => { + const user = userEvent.setup() + mockGuildStore() + vi.mocked(api.embeds.list).mockResolvedValue([mockTemplate]) + + render( + + + , + ) + + await waitFor(() => screen.getByText('welcome-message')) + + const editButton = screen.getByLabelText('Edit welcome-message') + await user.click(editButton) + + expect(screen.getByText('Edit Embed Template')).toBeInTheDocument() + }) + + test('shows validation error when template name is empty', async () => { + const user = userEvent.setup() + mockGuildStore() + vi.mocked(api.embeds.list).mockResolvedValue([]) + + render( + + + , + ) + + await waitFor(() => screen.getByText('No embed templates')) + + const newButton = screen.getAllByText(/New Template/)[0] + await user.click(newButton) + + const saveButtons = screen.getAllByRole('button', { + name: 'Create Template', + }) + await user.click(saveButtons[saveButtons.length - 1]) + + expect(screen.getByText('Name is required')).toBeInTheDocument() + }) + + test('creates new template successfully', async () => { + const user = userEvent.setup() + mockGuildStore() + vi.mocked(api.embeds.list).mockResolvedValue([]) + vi.mocked(api.embeds.create).mockResolvedValue(mockTemplate) + + render( + + + , + ) + + await waitFor(() => screen.getByText('No embed templates')) + + const newButton = screen.getAllByText(/New Template/)[0] + await user.click(newButton) + + const nameInput = screen.getByPlaceholderText('e.g. welcome-message') + await user.type(nameInput, 'test-embed') + + const titleInput = screen.getByPlaceholderText('Embed title') + await user.type(titleInput, 'Test Title') + + vi.mocked(api.embeds.list).mockResolvedValue([mockTemplate]) + + const saveButtons = screen.getAllByRole('button', { + name: 'Create Template', + }) + await user.click(saveButtons[saveButtons.length - 1]) + + await waitFor(() => { + expect(api.embeds.create).toHaveBeenCalledWith('123', { + name: 'test-embed', + embedData: expect.objectContaining({ + title: 'Test Title', + }), + }) + }) + }) + + test('updates existing template successfully', async () => { + const user = userEvent.setup() + mockGuildStore() + vi.mocked(api.embeds.list).mockResolvedValue([mockTemplate]) + vi.mocked(api.embeds.update).mockResolvedValue({ + ...mockTemplate, + title: 'Updated Title', + }) + + render( + + + , + ) + + await waitFor(() => screen.getByText('welcome-message')) + + const editButton = screen.getByLabelText('Edit welcome-message') + await user.click(editButton) + + const titleInput = screen.getByDisplayValue('Welcome!') + await user.clear(titleInput) + await user.type(titleInput, 'Updated Title') + + const saveButton = screen.getByText('Save Changes') + await user.click(saveButton) + + await waitFor(() => { + expect(api.embeds.update).toHaveBeenCalledWith( + '123', + 'welcome-message', + expect.objectContaining({ + title: 'Updated Title', + }), + ) + }) + }) + + test('adds and removes fields in embed builder', async () => { + const user = userEvent.setup() + mockGuildStore() + vi.mocked(api.embeds.list).mockResolvedValue([]) + + render( + + + , + ) + + await waitFor(() => screen.getByText('No embed templates')) + + const newButton = screen.getAllByText(/New Template/)[0] + await user.click(newButton) + + const addFieldButton = screen.getByText('Add Field') + await user.click(addFieldButton) + + const fieldNameInput = screen.getByPlaceholderText('Field name') + expect(fieldNameInput).toBeInTheDocument() + + const fieldValueInput = screen.getByPlaceholderText('Field value') + expect(fieldValueInput).toBeInTheDocument() + + const removeButton = screen.getByLabelText('Remove field 1') + await user.click(removeButton) + + expect( + screen.queryByPlaceholderText('Field name'), + ).not.toBeInTheDocument() + }) + + test('opens delete confirmation modal', async () => { + const user = userEvent.setup() + mockGuildStore() + vi.mocked(api.embeds.list).mockResolvedValue([mockTemplate]) + + render( + + + , + ) + + await waitFor(() => screen.getByText('welcome-message')) + + const deleteButton = screen.getByLabelText('Delete welcome-message') + await user.click(deleteButton) + + expect(screen.getByText('Delete Template')).toBeInTheDocument() + expect( + screen.getByRole('button', { name: 'Delete' }), + ).toBeInTheDocument() + }) + + test('deletes template successfully', async () => { + const user = userEvent.setup() + mockGuildStore() + vi.mocked(api.embeds.list).mockResolvedValue([mockTemplate]) + vi.mocked(api.embeds.delete).mockResolvedValue(undefined) + + render( + + + , + ) + + await waitFor(() => screen.getByText('welcome-message')) + + const deleteButton = screen.getByLabelText('Delete welcome-message') + await user.click(deleteButton) + + vi.mocked(api.embeds.list).mockResolvedValue([]) + + const confirmButton = screen.getByRole('button', { name: 'Delete' }) + await user.click(confirmButton) + + await waitFor(() => { + expect(api.embeds.delete).toHaveBeenCalledWith( + '123', + 'welcome-message', + ) + }) + }) + + test('shows preview when form has content', async () => { + const user = userEvent.setup() + mockGuildStore() + vi.mocked(api.embeds.list).mockResolvedValue([]) + + render( + + + , + ) + + await waitFor(() => screen.getByText('No embed templates')) + + const newButton = screen.getAllByText(/New Template/)[0] + await user.click(newButton) + + expect( + screen.getByText('Fill in the fields to see a preview'), + ).toBeInTheDocument() + + const titleInput = screen.getByPlaceholderText('Embed title') + await user.type(titleInput, 'Preview Test') + + await waitFor(() => { + expect( + screen.queryByText('Fill in the fields to see a preview'), + ).not.toBeInTheDocument() + }) + }) + + test('handles API error when loading templates', async () => { + mockGuildStore() + vi.mocked(api.embeds.list).mockRejectedValue(new Error('Network error')) + + render( + + + , + ) + + await waitFor(() => { + expect(screen.getByText('No embed templates')).toBeInTheDocument() + }) + }) + + test('closes modal when cancel button clicked', async () => { + const user = userEvent.setup() + mockGuildStore() + vi.mocked(api.embeds.list).mockResolvedValue([]) + + render( + + + , + ) + + await waitFor(() => screen.getByText('No embed templates')) + + const newButton = screen.getAllByText(/New Template/)[0] + await user.click(newButton) + + expect(screen.getByText('New Embed Template')).toBeInTheDocument() + + const cancelButtons = screen.getAllByRole('button', { name: 'Cancel' }) + await user.click(cancelButtons[0]) + + await waitFor(() => { + expect( + screen.queryByText('New Embed Template'), + ).not.toBeInTheDocument() + }) + }) + + test('displays color picker and hex input', async () => { + const user = userEvent.setup() + mockGuildStore() + vi.mocked(api.embeds.list).mockResolvedValue([]) + + render( + + + , + ) + + await waitFor(() => screen.getByText('No embed templates')) + + const newButton = screen.getAllByText(/New Template/)[0] + await user.click(newButton) + + const colorInput = screen.getByDisplayValue('#5865F2') + expect(colorInput).toBeInTheDocument() + }) + + test('toggles inline field option', async () => { + const user = userEvent.setup() + mockGuildStore() + vi.mocked(api.embeds.list).mockResolvedValue([]) + + render( + + + , + ) + + await waitFor(() => screen.getByText('No embed templates')) + + const newButton = screen.getAllByText(/New Template/)[0] + await user.click(newButton) + + const addFieldButton = screen.getByText('Add Field') + await user.click(addFieldButton) + + const inlineCheckbox = screen.getByLabelText('Inline') + expect(inlineCheckbox).not.toBeChecked() + + await user.click(inlineCheckbox) + expect(inlineCheckbox).toBeChecked() + }) +}) diff --git a/packages/frontend/src/pages/GuildAutomation.test.tsx b/packages/frontend/src/pages/GuildAutomation.test.tsx new file mode 100644 index 000000000..2dd55f500 --- /dev/null +++ b/packages/frontend/src/pages/GuildAutomation.test.tsx @@ -0,0 +1,657 @@ +import { describe, test, expect, vi, beforeEach } from 'vitest' +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { MemoryRouter } from 'react-router-dom' +import GuildAutomation from './GuildAutomation' +import { useGuildStore } from '@/stores/guildStore' +import { api } from '@/services/api' +import type { + AutomationRun, + GuildAutomationManifest, + PlanResult, + ApplyResult, +} from '@/services/automationApi' +import { toast } from 'sonner' + +vi.mock('@/stores/guildStore') +vi.mock('@/services/api', () => ({ + api: { + automation: { + getStatus: vi.fn(), + getManifest: vi.fn(), + updateManifest: vi.fn(), + plan: vi.fn(), + apply: vi.fn(), + reconcile: vi.fn(), + }, + }, +})) +vi.mock('@/hooks/usePageMetadata', () => ({ usePageMetadata: vi.fn() })) +vi.mock('sonner', () => ({ + toast: { + success: vi.fn(), + error: vi.fn(), + }, +})) + +function mockGuildStore(overrides: any = {}) { + vi.mocked(useGuildStore).mockImplementation((selector?: any) => { + const state = { + selectedGuild: { id: '456', name: 'Test Automation Guild' }, + ...overrides, + } + return typeof selector === 'function' ? selector(state) : state + }) +} + +const mockRun: AutomationRun = { + id: 'run-1', + guildId: '456', + type: 'plan', + status: 'success', + summary: 'Plan executed successfully', + createdAt: '2026-03-30T10:00:00Z', + completedAt: '2026-03-30T10:01:00Z', +} + +const mockManifest: GuildAutomationManifest = { + guildId: '456', + version: '1', + roles: { admin: { name: 'Admin', permissions: [] } }, + channels: { general: { name: 'general', type: 'text' } }, +} + +const mockPlanResult: PlanResult = { + summary: 'Found 2 changes to apply', + changes: [ + { + type: 'role', + resource: 'moderator', + action: 'create', + details: {}, + }, + { + type: 'channel', + resource: 'announcements', + action: 'update', + details: {}, + }, + ], +} + +const mockApplyResult: ApplyResult = { + applied: 2, + failed: 0, + summary: 'Applied 2 changes successfully', + changes: [ + { + type: 'role', + resource: 'moderator', + action: 'create', + status: 'success', + }, + { + type: 'channel', + resource: 'announcements', + action: 'update', + status: 'success', + }, + ], +} + +describe('GuildAutomation', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + test('shows empty state when no guild selected', () => { + mockGuildStore({ selectedGuild: null }) + + render( + + + , + ) + + expect(screen.getByText('No server selected')).toBeInTheDocument() + expect( + screen.getByText('Select a server to manage Guild Automation.'), + ).toBeInTheDocument() + }) + + test('renders automation status card when guild selected', async () => { + mockGuildStore() + vi.mocked(api.automation.getStatus).mockResolvedValue({ + status: 'applied', + runs: [], + }) + vi.mocked(api.automation.getManifest).mockResolvedValue(mockManifest) + + render( + + + , + ) + + await waitFor(() => { + expect(screen.getByText('Guild Automation')).toBeInTheDocument() + expect(screen.getByText('Automation Status')).toBeInTheDocument() + }) + }) + + test('displays status badge correctly', async () => { + mockGuildStore() + vi.mocked(api.automation.getStatus).mockResolvedValue({ + status: 'applied', + runs: [], + }) + vi.mocked(api.automation.getManifest).mockResolvedValue(mockManifest) + + render( + + + , + ) + + await waitFor(() => { + expect(screen.getByText('applied')).toBeInTheDocument() + }) + }) + + test('renders action buttons', async () => { + mockGuildStore() + vi.mocked(api.automation.getStatus).mockResolvedValue({ + status: 'applied', + runs: [], + }) + vi.mocked(api.automation.getManifest).mockResolvedValue(mockManifest) + + render( + + + , + ) + + await waitFor(() => { + expect(screen.getByText('Plan')).toBeInTheDocument() + expect(screen.getByText('Apply')).toBeInTheDocument() + expect(screen.getByText('Reconcile')).toBeInTheDocument() + }) + }) + + test('executes plan action successfully', async () => { + const user = userEvent.setup() + mockGuildStore() + vi.mocked(api.automation.getStatus).mockResolvedValue({ + status: 'applied', + runs: [], + }) + vi.mocked(api.automation.getManifest).mockResolvedValue(mockManifest) + vi.mocked(api.automation.plan).mockResolvedValue(mockPlanResult) + + render( + + + , + ) + + await waitFor(() => screen.getByText('Plan')) + + const planButton = screen.getByText('Plan') + await user.click(planButton) + + await waitFor(() => { + expect(api.automation.plan).toHaveBeenCalledWith('456') + expect(toast.success).toHaveBeenCalledWith('Plan generated.') + }) + }) + + test('displays plan result with changes', async () => { + const user = userEvent.setup() + mockGuildStore() + vi.mocked(api.automation.getStatus).mockResolvedValue({ + status: 'applied', + runs: [], + }) + vi.mocked(api.automation.getManifest).mockResolvedValue(mockManifest) + vi.mocked(api.automation.plan).mockResolvedValue(mockPlanResult) + + render( + + + , + ) + + await waitFor(() => screen.getByText('Plan')) + + const planButton = screen.getByText('Plan') + await user.click(planButton) + + await waitFor(() => { + expect(screen.getByText('Plan Result')).toBeInTheDocument() + expect( + screen.getByText('Found 2 changes to apply'), + ).toBeInTheDocument() + expect(screen.getByText('moderator')).toBeInTheDocument() + expect(screen.getByText('announcements')).toBeInTheDocument() + }) + }) + + test('executes apply action successfully', async () => { + const user = userEvent.setup() + mockGuildStore() + vi.mocked(api.automation.getStatus).mockResolvedValue({ + status: 'applied', + runs: [], + }) + vi.mocked(api.automation.getManifest).mockResolvedValue(mockManifest) + vi.mocked(api.automation.apply).mockResolvedValue(mockApplyResult) + + render( + + + , + ) + + await waitFor(() => screen.getByText('Apply')) + + const applyButton = screen.getByText('Apply') + await user.click(applyButton) + + await waitFor(() => { + expect(api.automation.apply).toHaveBeenCalledWith('456') + expect(toast.success).toHaveBeenCalledWith('Changes applied.') + }) + }) + + test('displays apply result with statistics', async () => { + const user = userEvent.setup() + mockGuildStore() + vi.mocked(api.automation.getStatus).mockResolvedValue({ + status: 'applied', + runs: [], + }) + vi.mocked(api.automation.getManifest).mockResolvedValue(mockManifest) + vi.mocked(api.automation.apply).mockResolvedValue(mockApplyResult) + + render( + + + , + ) + + await waitFor(() => screen.getByText('Apply')) + + const applyButton = screen.getByText('Apply') + await user.click(applyButton) + + await waitFor(() => { + expect(screen.getByText('Apply Result')).toBeInTheDocument() + expect(screen.getByText('2 applied')).toBeInTheDocument() + expect( + screen.getByText('Applied 2 changes successfully'), + ).toBeInTheDocument() + }) + }) + + test('executes reconcile action successfully', async () => { + const user = userEvent.setup() + mockGuildStore() + vi.mocked(api.automation.getStatus).mockResolvedValue({ + status: 'applied', + runs: [], + }) + vi.mocked(api.automation.getManifest).mockResolvedValue(mockManifest) + vi.mocked(api.automation.reconcile).mockResolvedValue(mockApplyResult) + + render( + + + , + ) + + await waitFor(() => screen.getByText('Reconcile')) + + const reconcileButton = screen.getByText('Reconcile') + await user.click(reconcileButton) + + await waitFor(() => { + expect(api.automation.reconcile).toHaveBeenCalledWith('456') + expect(toast.success).toHaveBeenCalledWith( + 'Reconciliation complete.', + ) + }) + }) + + test('displays manifest editor collapsed by default', async () => { + mockGuildStore() + vi.mocked(api.automation.getStatus).mockResolvedValue({ + status: 'applied', + runs: [], + }) + vi.mocked(api.automation.getManifest).mockResolvedValue(mockManifest) + + render( + + + , + ) + + await waitFor(() => { + expect(screen.getByText('Manifest')).toBeInTheDocument() + expect(screen.getByText('v1')).toBeInTheDocument() + }) + + expect(screen.queryByText('Save Manifest')).not.toBeInTheDocument() + }) + + test('expands and shows manifest editor', async () => { + const user = userEvent.setup() + mockGuildStore() + vi.mocked(api.automation.getStatus).mockResolvedValue({ + status: 'applied', + runs: [], + }) + vi.mocked(api.automation.getManifest).mockResolvedValue(mockManifest) + + render( + + + , + ) + + await waitFor(() => screen.getByText('Manifest')) + + const expandButton = screen.getByText('Expand') + await user.click(expandButton) + + await waitFor(() => { + expect(screen.getByText('Save Manifest')).toBeInTheDocument() + }) + }) + + test('saves manifest successfully', async () => { + const user = userEvent.setup() + mockGuildStore() + vi.mocked(api.automation.getStatus).mockResolvedValue({ + status: 'applied', + runs: [], + }) + vi.mocked(api.automation.getManifest).mockResolvedValue(mockManifest) + vi.mocked(api.automation.updateManifest).mockResolvedValue({ + guildId: '456', + version: '2', + updatedAt: '2026-03-30T12:00:00Z', + }) + + render( + + + , + ) + + await waitFor(() => screen.getByText('Manifest')) + + const expandButton = screen.getByText('Expand') + await user.click(expandButton) + + await waitFor(() => screen.getByText('Save Manifest')) + + const saveButton = screen.getByText('Save Manifest') + await user.click(saveButton) + + await waitFor(() => { + expect(api.automation.updateManifest).toHaveBeenCalled() + expect(toast.success).toHaveBeenCalledWith( + 'Manifest saved successfully.', + ) + }) + }) + + test('shows error when manifest JSON is invalid', async () => { + const user = userEvent.setup() + mockGuildStore() + vi.mocked(api.automation.getStatus).mockResolvedValue({ + status: 'applied', + runs: [], + }) + vi.mocked(api.automation.getManifest).mockResolvedValue(null) + + render( + + + , + ) + + await waitFor(() => screen.getByText('Manifest')) + + const expandButton = screen.getByText('Expand') + await user.click(expandButton) + + await waitFor(() => screen.getByText('Save Manifest')) + + const textarea = screen.getByPlaceholderText(/guildId/) + await user.clear(textarea) + await user.type(textarea, 'invalid json') + + const saveButton = screen.getByText('Save Manifest') + await user.click(saveButton) + + await waitFor(() => { + expect( + screen.getByText( + 'Invalid JSON — please fix syntax errors before saving.', + ), + ).toBeInTheDocument() + }) + }) + + test('displays run history when runs exist', async () => { + mockGuildStore() + vi.mocked(api.automation.getStatus).mockResolvedValue({ + status: 'applied', + runs: [mockRun], + }) + vi.mocked(api.automation.getManifest).mockResolvedValue(mockManifest) + + render( + + + , + ) + + await waitFor(() => { + expect(screen.getByText('Run History')).toBeInTheDocument() + expect(screen.getByText('plan')).toBeInTheDocument() + expect(screen.getByText('success')).toBeInTheDocument() + }) + }) + + test('shows empty state when no runs exist', async () => { + mockGuildStore() + vi.mocked(api.automation.getStatus).mockResolvedValue({ + status: 'applied', + runs: [], + }) + vi.mocked(api.automation.getManifest).mockResolvedValue(mockManifest) + + render( + + + , + ) + + await waitFor(() => { + expect(screen.getByText('No runs yet')).toBeInTheDocument() + expect( + screen.getByText('Run a plan or apply to see results here.'), + ).toBeInTheDocument() + }) + }) + + test('displays run with summary text', async () => { + const runWithSummary: AutomationRun = { + ...mockRun, + summary: 'Plan executed successfully', + } + + mockGuildStore() + vi.mocked(api.automation.getStatus).mockResolvedValue({ + status: 'applied', + runs: [runWithSummary], + }) + vi.mocked(api.automation.getManifest).mockResolvedValue(mockManifest) + + render( + + + , + ) + + await waitFor(() => { + expect(screen.getByText('plan')).toBeInTheDocument() + expect(screen.getByText('success')).toBeInTheDocument() + }) + }) + + test('displays error message in run card when error exists', async () => { + const runWithError: AutomationRun = { + ...mockRun, + status: 'failed', + error: 'Failed to create role: permission denied', + } + + mockGuildStore() + vi.mocked(api.automation.getStatus).mockResolvedValue({ + status: 'failed', + runs: [runWithError], + }) + vi.mocked(api.automation.getManifest).mockResolvedValue(mockManifest) + + render( + + + , + ) + + await waitFor(() => { + expect( + screen.getByText('Failed to create role: permission denied'), + ).toBeInTheDocument() + }) + }) + + test('refreshes data when refresh button clicked', async () => { + const user = userEvent.setup() + mockGuildStore() + vi.mocked(api.automation.getStatus).mockResolvedValue({ + status: 'applied', + runs: [], + }) + vi.mocked(api.automation.getManifest).mockResolvedValue(mockManifest) + + render( + + + , + ) + + await waitFor(() => screen.getByText('Automation Status')) + + vi.clearAllMocks() + + const refreshButtons = document.querySelectorAll('button') + const refreshButton = Array.from(refreshButtons).find((btn) => + btn.querySelector('svg'), + ) + + if (refreshButton) { + await user.click(refreshButton) + + await waitFor(() => { + expect(api.automation.getStatus).toHaveBeenCalled() + expect(api.automation.getManifest).toHaveBeenCalled() + }) + } + }) + + test('handles API errors gracefully', async () => { + mockGuildStore() + vi.mocked(api.automation.getStatus).mockRejectedValue( + new Error('Network error'), + ) + vi.mocked(api.automation.getManifest).mockRejectedValue( + new Error('Network error'), + ) + + render( + + + , + ) + + await waitFor(() => { + expect(screen.getByText('Guild Automation')).toBeInTheDocument() + }) + }) + + test('shows no manifest warning when manifest is null', async () => { + const user = userEvent.setup() + mockGuildStore() + vi.mocked(api.automation.getStatus).mockResolvedValue({ + status: 'unknown', + runs: [], + }) + vi.mocked(api.automation.getManifest).mockResolvedValue(null) + + render( + + + , + ) + + await waitFor(() => screen.getByText('Manifest')) + + const expandButton = screen.getByText('Expand') + await user.click(expandButton) + + await waitFor(() => { + expect( + screen.getByText( + /No manifest found. Paste or write a manifest below to get started/, + ), + ).toBeInTheDocument() + }) + }) + + test('disables action buttons when action is in progress', async () => { + const user = userEvent.setup() + mockGuildStore() + vi.mocked(api.automation.getStatus).mockResolvedValue({ + status: 'applied', + runs: [], + }) + vi.mocked(api.automation.getManifest).mockResolvedValue(mockManifest) + vi.mocked(api.automation.plan).mockImplementation( + () => new Promise(() => {}), + ) + + render( + + + , + ) + + await waitFor(() => screen.getByText('Plan')) + + const planButton = screen.getByText('Plan') + await user.click(planButton) + + const applyButton = screen.getByText('Apply') + const reconcileButton = screen.getByText('Reconcile') + + expect(planButton).toBeDisabled() + expect(applyButton).toBeDisabled() + expect(reconcileButton).toBeDisabled() + }) +})