diff --git a/tests/commands/challenge.test.js b/tests/commands/challenge.test.js index 51bd3f10..3777588c 100644 --- a/tests/commands/challenge.test.js +++ b/tests/commands/challenge.test.js @@ -114,7 +114,6 @@ import { getPool } from '../../src/db.js'; import { buildChallengeButtons, buildChallengeEmbed, - getLocalDateString, selectTodaysChallenge, } from '../../src/modules/challengeScheduler.js'; import { getConfig } from '../../src/modules/config.js'; diff --git a/tests/commands/ticket.test.js b/tests/commands/ticket.test.js deleted file mode 100644 index b93e3a60..00000000 --- a/tests/commands/ticket.test.js +++ /dev/null @@ -1,536 +0,0 @@ -/** - * Tests for src/commands/ticket.js - * Covers /ticket command subcommands: open, close, add, remove, panel - */ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; - -// ── Mocks ──────────────────────────────────────────────────────── - -const mockOpenTicket = vi.fn(); -const mockCloseTicket = vi.fn(); -const mockAddMember = vi.fn(); -const mockRemoveMember = vi.fn(); -const mockBuildTicketPanel = vi.fn(); -const mockGetTicketConfig = vi.fn(); - -vi.mock('../../src/db.js', () => ({ - getPool: vi.fn(), -})); - -vi.mock('../../src/logger.js', () => ({ - info: vi.fn(), - warn: vi.fn(), - error: vi.fn(), -})); - -vi.mock('../../src/modules/config.js', () => ({ - getConfig: vi.fn().mockReturnValue({ - permissions: { enabled: true, adminRoleId: null, usePermissions: true }, - }), -})); - -vi.mock('../../src/modules/ticketHandler.js', () => ({ - openTicket: (...args) => mockOpenTicket(...args), - closeTicket: (...args) => mockCloseTicket(...args), - addMember: (...args) => mockAddMember(...args), - removeMember: (...args) => mockRemoveMember(...args), - buildTicketPanel: (...args) => mockBuildTicketPanel(...args), - getTicketConfig: (...args) => mockGetTicketConfig(...args), -})); - -vi.mock('../../src/utils/permissions.js', () => ({ - isModerator: vi.fn().mockReturnValue(true), -})); - -vi.mock('../../src/utils/safeSend.js', () => ({ - safeSend: vi.fn().mockResolvedValue(undefined), - safeReply: vi.fn().mockResolvedValue(undefined), - safeEditReply: vi.fn().mockResolvedValue(undefined), -})); - -vi.mock('discord.js', () => { - function chainable() { - const proxy = new Proxy(() => proxy, { - get: () => () => proxy, - apply: () => proxy, - }); - return proxy; - } - - class MockSlashCommandBuilder { - constructor() { - this.name = ''; - this.description = ''; - } - setName(name) { - this.name = name; - return this; - } - setDescription(desc) { - this.description = desc; - return this; - } - addSubcommand(fn) { - const sub = { - setName: () => ({ - setDescription: () => ({ - addStringOption: function self(fn2) { - fn2(chainable()); - return { - addStringOption: self, - addUserOption: self, - addChannelOption: self, - }; - }, - addUserOption: function self(fn2) { - fn2(chainable()); - return { - addStringOption: self, - addUserOption: self, - addChannelOption: self, - }; - }, - addChannelOption: function self(fn2) { - fn2(chainable()); - return { - addStringOption: self, - addUserOption: self, - addChannelOption: self, - }; - }, - }), - }), - }; - fn(sub); - return this; - } - toJSON() { - return { name: this.name, description: this.description }; - } - } - - return { - SlashCommandBuilder: MockSlashCommandBuilder, - ChannelType: { - GuildText: 0, - PrivateThread: 12, - }, - PermissionFlagsBits: { - Administrator: 8n, - }, - }; -}); - -import { safeEditReply, safeSend } from '../../src/utils/safeSend.js'; -import { getConfig } from '../../src/modules/config.js'; -import { isModerator } from '../../src/utils/permissions.js'; - -// ── Helpers ────────────────────────────────────────────────────── - -function createMockInteraction(overrides = {}) { - return { - guildId: 'guild1', - channelId: 'channel1', - user: { id: 'user1', tag: 'User#1234' }, - member: { - permissions: { - has: vi.fn().mockReturnValue(false), - }, - }, - options: { - getSubcommand: vi.fn().mockReturnValue('open'), - getString: vi.fn().mockReturnValue(null), - getUser: vi.fn().mockReturnValue(null), - getChannel: vi.fn().mockReturnValue(null), - }, - channel: { - id: 'channel1', - type: 0, // GuildText - isThread: () => false, - }, - guild: { - id: 'guild1', - tag: 'Guild#1234', - }, - deferReply: vi.fn().mockResolvedValue(undefined), - ...overrides, - }; -} - -function createMockThread(overrides = {}) { - return { - id: 'thread1', - type: 12, // PrivateThread - isThread: () => true, - ...overrides, - }; -} - -// ── Tests ──────────────────────────────────────────────────────── - -describe('/ticket command', () => { - let execute; - - beforeEach(async () => { - vi.clearAllMocks(); - mockGetTicketConfig.mockReturnValue({ enabled: true }); - - // Dynamically import after mocks are set up - const mod = await import('../../src/commands/ticket.js'); - execute = mod.execute; - }); - - afterEach(() => { - vi.clearAllMocks(); - }); - - // ─── /ticket open ──────────────────────────────────────────── - - describe('/ticket open', () => { - it('should create a ticket successfully', async () => { - const interaction = createMockInteraction(); - interaction.options.getSubcommand.mockReturnValue('open'); - interaction.options.getString.mockReturnValue('Need help with bot'); - - mockOpenTicket.mockResolvedValue({ - ticket: { id: 1 }, - thread: { id: 'thread1' }, - }); - - await execute(interaction); - - expect(interaction.deferReply).toHaveBeenCalledWith({ ephemeral: true }); - expect(mockOpenTicket).toHaveBeenCalledWith( - interaction.guild, - interaction.user, - 'Need help with bot', - 'channel1', - ); - expect(safeEditReply).toHaveBeenCalledWith(interaction, { - content: '✅ Ticket #1 created! Head to <#thread1>.', - }); - }); - - it('should create a ticket without a topic', async () => { - const interaction = createMockInteraction(); - interaction.options.getSubcommand.mockReturnValue('open'); - interaction.options.getString.mockReturnValue(null); - - mockOpenTicket.mockResolvedValue({ - ticket: { id: 2 }, - thread: { id: 'thread2' }, - }); - - await execute(interaction); - - expect(mockOpenTicket).toHaveBeenCalledWith( - interaction.guild, - interaction.user, - null, - 'channel1', - ); - expect(safeEditReply).toHaveBeenCalledWith(interaction, { - content: '✅ Ticket #2 created! Head to <#thread2>.', - }); - }); - - it('should handle ticket creation errors', async () => { - const interaction = createMockInteraction(); - interaction.options.getSubcommand.mockReturnValue('open'); - - mockOpenTicket.mockRejectedValue(new Error('Max open tickets reached')); - - await execute(interaction); - - expect(safeEditReply).toHaveBeenCalledWith(interaction, { - content: '❌ Max open tickets reached', - }); - }); - - it('should return error when ticket system is disabled', async () => { - const interaction = createMockInteraction(); - mockGetTicketConfig.mockReturnValue({ enabled: false }); - - await execute(interaction); - - expect(safeEditReply).toHaveBeenCalledWith(interaction, { - content: '❌ The ticket system is not enabled on this server.', - }); - expect(mockOpenTicket).not.toHaveBeenCalled(); - }); - }); - - // ─── /ticket close ─────────────────────────────────────────── - - describe('/ticket close', () => { - it('should close a ticket successfully', async () => { - const thread = createMockThread(); - const interaction = createMockInteraction({ channel: thread }); - interaction.options.getSubcommand.mockReturnValue('close'); - interaction.options.getString.mockReturnValue('Issue resolved'); - - mockCloseTicket.mockResolvedValue({ id: 1 }); - - await execute(interaction); - - expect(mockCloseTicket).toHaveBeenCalledWith(thread, interaction.user, 'Issue resolved'); - expect(safeEditReply).toHaveBeenCalledWith(interaction, { - content: '✅ Ticket #1 has been closed.', - }); - }); - - it('should close a ticket without a reason', async () => { - const thread = createMockThread(); - const interaction = createMockInteraction({ channel: thread }); - interaction.options.getSubcommand.mockReturnValue('close'); - interaction.options.getString.mockReturnValue(null); - - mockCloseTicket.mockResolvedValue({ id: 3 }); - - await execute(interaction); - - expect(mockCloseTicket).toHaveBeenCalledWith(thread, interaction.user, null); - expect(safeEditReply).toHaveBeenCalledWith(interaction, { - content: '✅ Ticket #3 has been closed.', - }); - }); - - it('should reject close outside a ticket context', async () => { - const interaction = createMockInteraction(); - interaction.options.getSubcommand.mockReturnValue('close'); - - await execute(interaction); - - expect(mockCloseTicket).not.toHaveBeenCalled(); - expect(safeEditReply).toHaveBeenCalledWith(interaction, { - content: '❌ This command must be used inside a ticket thread or channel.', - }); - }); - - it('should handle close errors', async () => { - const thread = createMockThread(); - const interaction = createMockInteraction({ channel: thread }); - interaction.options.getSubcommand.mockReturnValue('close'); - - mockCloseTicket.mockRejectedValue(new Error('No open ticket found')); - - await execute(interaction); - - expect(safeEditReply).toHaveBeenCalledWith(interaction, { - content: '❌ No open ticket found', - }); - }); - }); - - // ─── /ticket add ───────────────────────────────────────────── - - describe('/ticket add', () => { - it('should add a user to the ticket', async () => { - const thread = createMockThread(); - const interaction = createMockInteraction({ channel: thread }); - const targetUser = { id: 'user2', tag: 'Helper#5678' }; - - interaction.options.getSubcommand.mockReturnValue('add'); - interaction.options.getUser.mockReturnValue(targetUser); - - mockAddMember.mockResolvedValue(undefined); - - await execute(interaction); - - expect(mockAddMember).toHaveBeenCalledWith(thread, targetUser); - expect(safeEditReply).toHaveBeenCalledWith(interaction, { - content: '✅ <@user2> has been added to the ticket.', - }); - }); - - it('should reject add outside a ticket context', async () => { - const interaction = createMockInteraction(); - const targetUser = { id: 'user2' }; - - interaction.options.getSubcommand.mockReturnValue('add'); - interaction.options.getUser.mockReturnValue(targetUser); - - await execute(interaction); - - expect(mockAddMember).not.toHaveBeenCalled(); - expect(safeEditReply).toHaveBeenCalledWith(interaction, { - content: '❌ This command must be used inside a ticket thread or channel.', - }); - }); - - it('should handle add errors', async () => { - const thread = createMockThread(); - const interaction = createMockInteraction({ channel: thread }); - const targetUser = { id: 'user2' }; - - interaction.options.getSubcommand.mockReturnValue('add'); - interaction.options.getUser.mockReturnValue(targetUser); - - mockAddMember.mockRejectedValue(new Error('Missing permissions')); - - await execute(interaction); - - expect(safeEditReply).toHaveBeenCalledWith(interaction, { - content: '❌ Failed to add user: Missing permissions', - }); - }); - }); - - // ─── /ticket remove ────────────────────────────────────────── - - describe('/ticket remove', () => { - it('should remove a user from the ticket', async () => { - const thread = createMockThread(); - const interaction = createMockInteraction({ channel: thread }); - const targetUser = { id: 'user2', tag: 'Helper#5678' }; - - interaction.options.getSubcommand.mockReturnValue('remove'); - interaction.options.getUser.mockReturnValue(targetUser); - - mockRemoveMember.mockResolvedValue(undefined); - - await execute(interaction); - - expect(mockRemoveMember).toHaveBeenCalledWith(thread, targetUser); - expect(safeEditReply).toHaveBeenCalledWith(interaction, { - content: '✅ <@user2> has been removed from the ticket.', - }); - }); - - it('should reject remove outside a ticket context', async () => { - const interaction = createMockInteraction(); - const targetUser = { id: 'user2' }; - - interaction.options.getSubcommand.mockReturnValue('remove'); - interaction.options.getUser.mockReturnValue(targetUser); - - await execute(interaction); - - expect(mockRemoveMember).not.toHaveBeenCalled(); - expect(safeEditReply).toHaveBeenCalledWith(interaction, { - content: '❌ This command must be used inside a ticket thread or channel.', - }); - }); - - it('should handle remove errors', async () => { - const thread = createMockThread(); - const interaction = createMockInteraction({ channel: thread }); - const targetUser = { id: 'user2' }; - - interaction.options.getSubcommand.mockReturnValue('remove'); - interaction.options.getUser.mockReturnValue(targetUser); - - mockRemoveMember.mockRejectedValue(new Error('User not in ticket')); - - await execute(interaction); - - expect(safeEditReply).toHaveBeenCalledWith(interaction, { - content: '❌ Failed to remove user: User not in ticket', - }); - }); - }); - - // ─── /ticket panel ─────────────────────────────────────────── - - describe('/ticket panel', () => { - it('should post a ticket panel with admin permissions', async () => { - const interaction = createMockInteraction(); - interaction.options.getSubcommand.mockReturnValue('panel'); - interaction.member.permissions.has.mockReturnValue(true); // Admin - - const mockEmbed = { data: { title: '🎫 Support Tickets' } }; - const mockRow = { components: [{ data: { custom_id: 'ticket_open' } }] }; - mockBuildTicketPanel.mockReturnValue({ embed: mockEmbed, row: mockRow }); - - await execute(interaction); - - expect(mockBuildTicketPanel).toHaveBeenCalled(); - expect(safeSend).toHaveBeenCalledWith(interaction.channel, { - embeds: [mockEmbed], - components: [mockRow], - }); - expect(safeEditReply).toHaveBeenCalledWith(interaction, { - content: '✅ Ticket panel posted in <#channel1>.', - }); - }); - - it('should post panel to specified channel', async () => { - const targetChannel = { id: 'channel2', tag: 'Channel2' }; - const interaction = createMockInteraction(); - interaction.options.getSubcommand.mockReturnValue('panel'); - interaction.options.getChannel.mockReturnValue(targetChannel); - interaction.member.permissions.has.mockReturnValue(true); - - const mockEmbed = { data: {} }; - const mockRow = { components: [] }; - mockBuildTicketPanel.mockReturnValue({ embed: mockEmbed, row: mockRow }); - - await execute(interaction); - - expect(safeSend).toHaveBeenCalledWith(targetChannel, { - embeds: [mockEmbed], - components: [mockRow], - }); - expect(safeEditReply).toHaveBeenCalledWith(interaction, { - content: '✅ Ticket panel posted in <#channel2>.', - }); - }); - - it('should reject panel without admin permissions', async () => { - const interaction = createMockInteraction(); - interaction.options.getSubcommand.mockReturnValue('panel'); - interaction.member.permissions.has.mockReturnValue(false); - isModerator.mockReturnValue(false); - - await execute(interaction); - - expect(mockBuildTicketPanel).not.toHaveBeenCalled(); - expect(safeEditReply).toHaveBeenCalledWith(interaction, { - content: '❌ You need administrator permissions to use this command.', - }); - }); - - it('should allow panel with moderator role', async () => { - const interaction = createMockInteraction(); - interaction.options.getSubcommand.mockReturnValue('panel'); - interaction.member.permissions.has.mockReturnValue(false); - isModerator.mockReturnValue(true); - - const mockEmbed = { data: {} }; - const mockRow = { components: [] }; - mockBuildTicketPanel.mockReturnValue({ embed: mockEmbed, row: mockRow }); - - await execute(interaction); - - expect(mockBuildTicketPanel).toHaveBeenCalled(); - expect(safeSend).toHaveBeenCalled(); - }); - - it('should handle panel posting errors', async () => { - const interaction = createMockInteraction(); - interaction.options.getSubcommand.mockReturnValue('panel'); - interaction.member.permissions.has.mockReturnValue(true); - - mockBuildTicketPanel.mockReturnValue({ embed: {}, row: {} }); - safeSend.mockRejectedValue(new Error('Missing Permissions')); - - await execute(interaction); - - expect(safeEditReply).toHaveBeenCalledWith(interaction, { - content: '❌ Failed to post panel: Missing Permissions', - }); - }); - }); - - // ─── Command definition ────────────────────────────────────── - - describe('command definition', () => { - it('should have correct command structure', async () => { - const mod = await import('../../src/commands/ticket.js'); - const { data } = mod; - - expect(data.name).toBe('ticket'); - expect(data.description).toContain('ticket'); - }); - }); -}); \ No newline at end of file diff --git a/web/src/app/dashboard/members/[userId]/page.tsx b/web/src/app/dashboard/members/[userId]/page.tsx index a99f9c42..cfa248a4 100644 --- a/web/src/app/dashboard/members/[userId]/page.tsx +++ b/web/src/app/dashboard/members/[userId]/page.tsx @@ -354,8 +354,8 @@ export default function MemberDetailPage() {
- {Array.from({ length: 4 }).map((_, i) => ( - + {(['sk-0', 'sk-1', 'sk-2', 'sk-3'] as const).map((key) => ( + ))}
diff --git a/web/src/app/login/page.tsx b/web/src/app/login/page.tsx index 0e69ec3e..13d56163 100644 --- a/web/src/app/login/page.tsx +++ b/web/src/app/login/page.tsx @@ -14,7 +14,7 @@ function LoginForm() { // Validate callbackUrl is a safe relative path to prevent open redirects. // Reject absolute URLs, protocol-relative URLs (//evil.com), and missing values. const callbackUrl = - rawCallbackUrl && rawCallbackUrl.startsWith('/') && !rawCallbackUrl.startsWith('//') + rawCallbackUrl?.startsWith('/') && !rawCallbackUrl.startsWith('//') ? rawCallbackUrl : '/dashboard'; diff --git a/web/src/components/dashboard/analytics-dashboard.tsx b/web/src/components/dashboard/analytics-dashboard.tsx index 549c650c..f3c1f586 100644 --- a/web/src/components/dashboard/analytics-dashboard.tsx +++ b/web/src/components/dashboard/analytics-dashboard.tsx @@ -332,7 +332,9 @@ export function AnalyticsDashboard() { {/* KPI cards with loading skeleton */}
{showKpiSkeleton ? ( - Array.from({ length: 5 }).map((_, i) => ) + (['kpi-0', 'kpi-1', 'kpi-2', 'kpi-3', 'kpi-4'] as const).map((key) => ( + + )) ) : ( <> @@ -420,26 +422,32 @@ export function AnalyticsDashboard() { Online members
-

+ {analytics == null ? '\u2014' : analytics.realtime.onlineMembers === null ? 'N/A' : formatNumber(analytics.realtime.onlineMembers)} -

+
Active AI conversations
-

+ {loading || analytics == null ? '\u2014' : analytics.realtime.activeAiConversations === null ? 'N/A' : formatNumber(analytics.realtime.activeAiConversations)} -

+
diff --git a/web/src/components/dashboard/case-table.tsx b/web/src/components/dashboard/case-table.tsx index 2383f1dc..c38b41b6 100644 --- a/web/src/components/dashboard/case-table.tsx +++ b/web/src/components/dashboard/case-table.tsx @@ -220,6 +220,7 @@ export function CaseTable({ require a backend ORDER param — not worth it right now. */} + + {/* Expanded metadata — outside the button so text is selectable */} + {isExpanded && ( +
+
+              {JSON.stringify(entry.meta, null, 2)}
+            
+
+ )} + + ); + } + + return
{mainRow}
; } // ─── Main component ─────────────────────────────────────────────────────────── @@ -148,7 +146,7 @@ export function LogViewer({ logs, status, onClear }: LogViewerProps) { // Auto-scroll to bottom when new logs arrive (unless paused/user scrolled) useEffect(() => { - if (paused || userScrolledRef.current) return; + if (!logs.length || paused || userScrolledRef.current) return; bottomRef.current?.scrollIntoView({ behavior: 'instant' }); }, [logs, paused]); diff --git a/web/src/components/dashboard/member-table.tsx b/web/src/components/dashboard/member-table.tsx index 44aa9f19..4da77bb9 100644 --- a/web/src/components/dashboard/member-table.tsx +++ b/web/src/components/dashboard/member-table.tsx @@ -154,8 +154,8 @@ function SortableHead({ function TableSkeleton() { return ( <> - {Array.from({ length: 8 }).map((_, i) => ( - + {(['mt-0', 'mt-1', 'mt-2', 'mt-3', 'mt-4', 'mt-5', 'mt-6', 'mt-7'] as const).map((key) => ( + diff --git a/web/src/components/dashboard/restart-history.tsx b/web/src/components/dashboard/restart-history.tsx index 1e22751f..7507e1d0 100644 --- a/web/src/components/dashboard/restart-history.tsx +++ b/web/src/components/dashboard/restart-history.tsx @@ -99,8 +99,8 @@ function ReasonBadge({ reason }: { reason: string }) { function TableSkeleton() { return (
- {Array.from({ length: 5 }, (_, i) => ( - + {(['rh-0', 'rh-1', 'rh-2', 'rh-3', 'rh-4'] as const).map((key) => ( + ))}
); diff --git a/web/src/components/dashboard/system-prompt-editor.tsx b/web/src/components/dashboard/system-prompt-editor.tsx index eb5f1f15..970100c5 100644 --- a/web/src/components/dashboard/system-prompt-editor.tsx +++ b/web/src/components/dashboard/system-prompt-editor.tsx @@ -80,7 +80,7 @@ export function SystemPromptEditor({ placeholder="Enter the system prompt for your bot..." />
- {charCount.toLocaleString()} / {maxLength.toLocaleString()} - + {isOverLimit && ( ({(charCount - maxLength).toLocaleString()} over limit) diff --git a/web/src/lib/auth.ts b/web/src/lib/auth.ts index e5c06c7d..1192c5ac 100644 --- a/web/src/lib/auth.ts +++ b/web/src/lib/auth.ts @@ -5,11 +5,12 @@ import { logger } from '@/lib/logger'; // --- Runtime validation (deferred to request time, not module load / build) --- const PLACEHOLDER_PATTERN = /change|placeholder|example|replace.?me/i; +// Cache successful validation only. Failed validation is retried on later calls, +// which is safer for misconfigured environments and test setups that patch env. let envValidated = false; function validateEnv(): void { if (envValidated) return; - envValidated = true; const secret = process.env.NEXTAUTH_SECRET ?? ''; if (secret.length < 32 || PLACEHOLDER_PATTERN.test(secret)) { @@ -33,6 +34,9 @@ function validateEnv(): void { 'Set BOT_API_SECRET to secure bot API communication.', ); } + + // Mark validated only after all checks pass. + envValidated = true; } /** @@ -55,10 +59,19 @@ export async function refreshDiscordToken( logger.warn('[auth] Cannot refresh Discord token: refreshToken is missing or invalid'); return { ...token, error: 'RefreshTokenError' }; } + try { + validateEnv(); + } catch (error) { + logger.error( + '[auth] Cannot refresh Discord token: environment configuration is invalid', + error, + ); + return { ...token, error: 'RefreshTokenError' }; + } const params = new URLSearchParams({ - client_id: process.env.DISCORD_CLIENT_ID!, - client_secret: process.env.DISCORD_CLIENT_SECRET!, + client_id: process.env.DISCORD_CLIENT_ID as string, + client_secret: process.env.DISCORD_CLIENT_SECRET as string, grant_type: 'refresh_token', refresh_token: token.refreshToken as string, }); @@ -118,8 +131,8 @@ export function getAuthOptions(): AuthOptions { _authOptions = { providers: [ DiscordProvider({ - clientId: process.env.DISCORD_CLIENT_ID!, - clientSecret: process.env.DISCORD_CLIENT_SECRET!, + clientId: process.env.DISCORD_CLIENT_ID as string, + clientSecret: process.env.DISCORD_CLIENT_SECRET as string, authorization: { params: { scope: DISCORD_SCOPES, diff --git a/web/src/lib/discord.server.ts b/web/src/lib/discord.server.ts index 61cae12d..a1ad1753 100644 --- a/web/src/lib/discord.server.ts +++ b/web/src/lib/discord.server.ts @@ -46,7 +46,7 @@ export async function fetchWithRateLimit(url: string, init?: RequestInit): Promi await new Promise((resolve, reject) => { const onAbort = () => { clearTimeout(timer); - reject(signal!.reason); + reject(signal?.reason); }; const timer = setTimeout(() => { signal?.removeEventListener('abort', onAbort); @@ -71,6 +71,7 @@ export async function fetchUserGuilds( ): Promise { const allGuilds: DiscordGuild[] = []; let after: string | undefined; + let hasMore = true; do { const url = new URL(`${DISCORD_API_BASE}/users/@me/guilds`); @@ -110,13 +111,13 @@ export async function fetchUserGuilds( allGuilds.push(...page); // If we got fewer than the max, we've fetched everything - if (page.length < GUILDS_PER_PAGE) { - break; - } + hasMore = page.length >= GUILDS_PER_PAGE; // Set cursor to the last guild's ID for the next page - after = page[page.length - 1].id; - } while (true); + if (hasMore) { + after = page[page.length - 1].id; + } + } while (hasMore); return allGuilds; } diff --git a/web/tests/api/guilds/[guildId]/tickets/[ticketId]/route.test.ts b/web/tests/api/guilds/[guildId]/tickets/[ticketId]/route.test.ts deleted file mode 100644 index 8af5712a..00000000 --- a/web/tests/api/guilds/[guildId]/tickets/[ticketId]/route.test.ts +++ /dev/null @@ -1,303 +0,0 @@ -/** - * Tests for web/src/app/api/guilds/[guildId]/tickets/[ticketId]/route.ts - * Covers GET endpoint for fetching a specific ticket with transcript - */ -import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { NextRequest, NextResponse } from "next/server"; - -// Mock bot-api-proxy module -const mockAuthorizeGuildAdmin = vi.fn(); -const mockGetBotApiConfig = vi.fn(); -const mockBuildUpstreamUrl = vi.fn(); -const mockProxyToBotApi = vi.fn(); - -vi.mock("@/lib/bot-api-proxy", () => ({ - authorizeGuildAdmin: (...args: unknown[]) => mockAuthorizeGuildAdmin(...args), - getBotApiConfig: (...args: unknown[]) => mockGetBotApiConfig(...args), - buildUpstreamUrl: (...args: unknown[]) => mockBuildUpstreamUrl(...args), - proxyToBotApi: (...args: unknown[]) => mockProxyToBotApi(...args), -})); - -import { GET } from "@/app/api/guilds/[guildId]/tickets/[ticketId]/route"; - -function createMockRequest(guildId: string, ticketId: string): NextRequest { - const url = `http://localhost:3000/api/guilds/${guildId}/tickets/${ticketId}`; - return new NextRequest(new URL(url)); -} - -async function mockParams(guildId: string, ticketId: string) { - return { guildId, ticketId }; -} - -describe("GET /api/guilds/:guildId/tickets/:ticketId", () => { - beforeEach(() => { - vi.clearAllMocks(); - - // Default successful auth - mockAuthorizeGuildAdmin.mockResolvedValue(null); - - // Default successful config - mockGetBotApiConfig.mockReturnValue({ - baseUrl: "http://localhost:3001/api/v1", - secret: "test-secret", - }); - - // Default successful URL build - mockBuildUpstreamUrl.mockImplementation((baseUrl, path) => { - return new URL(path, baseUrl); - }); - - // Default successful proxy - mockProxyToBotApi.mockResolvedValue( - NextResponse.json({ - id: 1, - guild_id: "guild1", - user_id: "user1", - topic: "Need help", - status: "open", - thread_id: "thread1", - created_at: "2024-01-01T00:00:00Z", - transcript: null, - }) - ); - }); - - afterEach(() => { - vi.restoreAllMocks(); - }); - - // ─── Validation ────────────────────────────────────────────── - - it("should return 400 when guildId is missing", async () => { - const request = createMockRequest("", "1"); - const response = await GET(request, { params: mockParams("", "1") }); - - expect(response.status).toBe(400); - const body = await response.json(); - expect(body.error).toBe("Missing guildId or ticketId"); - }); - - it("should return 400 when ticketId is missing", async () => { - const request = createMockRequest("guild1", ""); - const response = await GET(request, { params: mockParams("guild1", "") }); - - expect(response.status).toBe(400); - const body = await response.json(); - expect(body.error).toBe("Missing guildId or ticketId"); - }); - - it("should return 400 when both IDs are missing", async () => { - const request = createMockRequest("", ""); - const response = await GET(request, { params: mockParams("", "") }); - - expect(response.status).toBe(400); - const body = await response.json(); - expect(body.error).toBe("Missing guildId or ticketId"); - }); - - // ─── Auth & Config ─────────────────────────────────────────── - - it("should return error when authorization fails", async () => { - const authError = NextResponse.json({ error: "Forbidden" }, { status: 403 }); - mockAuthorizeGuildAdmin.mockResolvedValue(authError); - - const request = createMockRequest("guild1", "1"); - const response = await GET(request, { params: mockParams("guild1", "1") }); - - expect(response).toBe(authError); - expect(mockAuthorizeGuildAdmin).toHaveBeenCalledWith( - request, - "guild1", - "[api/guilds/:guildId/tickets/:ticketId]" - ); - }); - - it("should return error when bot API config is invalid", async () => { - const configError = NextResponse.json( - { error: "Bot API not configured" }, - { status: 500 } - ); - mockGetBotApiConfig.mockReturnValue(configError); - - const request = createMockRequest("guild1", "1"); - const response = await GET(request, { params: mockParams("guild1", "1") }); - - expect(response).toBe(configError); - }); - - it("should return error when upstream URL build fails", async () => { - const urlError = NextResponse.json({ error: "Invalid URL" }, { status: 500 }); - mockBuildUpstreamUrl.mockReturnValue(urlError); - - const request = createMockRequest("guild1", "1"); - const response = await GET(request, { params: mockParams("guild1", "1") }); - - expect(response).toBe(urlError); - }); - - // ─── Successful requests ───────────────────────────────────── - - it("should fetch ticket detail successfully", async () => { - const mockTicket = { - id: 1, - guild_id: "guild1", - user_id: "user1", - topic: "Bug report", - status: "closed", - thread_id: "thread1", - channel_id: "channel1", - closed_by: "staff1", - close_reason: "Resolved", - created_at: "2024-01-01T00:00:00Z", - closed_at: "2024-01-01T01:00:00Z", - transcript: [ - { author: "User#1234", content: "I found a bug", timestamp: "2024-01-01T00:00:00Z" }, - { author: "Staff#5678", content: "Thanks, fixed!", timestamp: "2024-01-01T00:30:00Z" }, - ], - }; - - mockProxyToBotApi.mockResolvedValue(NextResponse.json(mockTicket)); - - const request = createMockRequest("guild1", "1"); - const response = await GET(request, { params: mockParams("guild1", "1") }); - - expect(response.status).toBe(200); - const body = await response.json(); - expect(body.id).toBe(1); - expect(body.transcript).toHaveLength(2); - expect(body.closed_by).toBe("staff1"); - }); - - it("should build correct upstream URL", async () => { - const request = createMockRequest("guild1", "42"); - await GET(request, { params: mockParams("guild1", "42") }); - - expect(mockBuildUpstreamUrl).toHaveBeenCalledWith( - "http://localhost:3001/api/v1", - "/guilds/guild1/tickets/42", - "[api/guilds/:guildId/tickets/:ticketId]" - ); - }); - - it("should call proxyToBotApi with correct parameters", async () => { - const upstreamUrl = new URL("http://localhost:3001/api/v1/guilds/guild1/tickets/1"); - mockBuildUpstreamUrl.mockReturnValue(upstreamUrl); - - const request = createMockRequest("guild1", "1"); - await GET(request, { params: mockParams("guild1", "1") }); - - expect(mockProxyToBotApi).toHaveBeenCalledWith( - upstreamUrl, - "test-secret", - "[api/guilds/:guildId/tickets/:ticketId]", - "Failed to fetch ticket" - ); - }); - - // ─── URL encoding ──────────────────────────────────────────── - - it("should properly encode guild ID in URL", async () => { - const guildIdWithSpecialChars = "guild/special"; - const request = createMockRequest(guildIdWithSpecialChars, "1"); - await GET(request, { params: mockParams(guildIdWithSpecialChars, "1") }); - - expect(mockBuildUpstreamUrl).toHaveBeenCalledWith( - expect.any(String), - "/guilds/guild%2Fspecial/tickets/1", - expect.any(String) - ); - }); - - it("should properly encode ticket ID in URL", async () => { - const ticketIdWithSpecialChars = "ticket#1"; - const request = createMockRequest("guild1", ticketIdWithSpecialChars); - await GET(request, { params: mockParams("guild1", ticketIdWithSpecialChars) }); - - expect(mockBuildUpstreamUrl).toHaveBeenCalledWith( - expect.any(String), - "/guilds/guild1/tickets/ticket%231", - expect.any(String) - ); - }); - - // ─── Error responses ───────────────────────────────────────── - - it("should handle 404 from upstream", async () => { - mockProxyToBotApi.mockResolvedValue( - NextResponse.json({ error: "Ticket not found" }, { status: 404 }) - ); - - const request = createMockRequest("guild1", "999"); - const response = await GET(request, { params: mockParams("guild1", "999") }); - - expect(response.status).toBe(404); - const body = await response.json(); - expect(body.error).toBe("Ticket not found"); - }); - - it("should handle 500 from upstream", async () => { - mockProxyToBotApi.mockResolvedValue( - NextResponse.json({ error: "Internal server error" }, { status: 500 }) - ); - - const request = createMockRequest("guild1", "1"); - const response = await GET(request, { params: mockParams("guild1", "1") }); - - expect(response.status).toBe(500); - }); - - // ─── Open tickets ──────────────────────────────────────────── - - it("should fetch open ticket without transcript", async () => { - const mockTicket = { - id: 2, - guild_id: "guild1", - user_id: "user2", - topic: "Question", - status: "open", - thread_id: "thread2", - channel_id: "channel2", - closed_by: null, - close_reason: null, - created_at: "2024-01-02T00:00:00Z", - closed_at: null, - transcript: null, - }; - - mockProxyToBotApi.mockResolvedValue(NextResponse.json(mockTicket)); - - const request = createMockRequest("guild1", "2"); - const response = await GET(request, { params: mockParams("guild1", "2") }); - - expect(response.status).toBe(200); - const body = await response.json(); - expect(body.status).toBe("open"); - expect(body.transcript).toBeNull(); - expect(body.closed_at).toBeNull(); - }); - - // ─── Edge cases ────────────────────────────────────────────── - - it("should handle numeric ticket IDs", async () => { - const request = createMockRequest("guild1", "123"); - await GET(request, { params: mockParams("guild1", "123") }); - - expect(mockBuildUpstreamUrl).toHaveBeenCalledWith( - expect.any(String), - "/guilds/guild1/tickets/123", - expect.any(String) - ); - }); - - it("should handle very large ticket IDs", async () => { - const largeId = "99999999999999"; - const request = createMockRequest("guild1", largeId); - await GET(request, { params: mockParams("guild1", largeId) }); - - expect(mockBuildUpstreamUrl).toHaveBeenCalledWith( - expect.any(String), - `/guilds/guild1/tickets/${largeId}`, - expect.any(String) - ); - }); -}); \ No newline at end of file diff --git a/web/tests/api/guilds/[guildId]/tickets/route.test.ts b/web/tests/api/guilds/[guildId]/tickets/route.test.ts deleted file mode 100644 index 4ff8d770..00000000 --- a/web/tests/api/guilds/[guildId]/tickets/route.test.ts +++ /dev/null @@ -1,293 +0,0 @@ -/** - * Tests for web/src/app/api/guilds/[guildId]/tickets/route.ts - * Covers GET endpoint for listing tickets with pagination and filters - */ -import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { NextRequest } from "next/server"; - -// Mock bot-api-proxy module -const mockAuthorizeGuildAdmin = vi.fn(); -const mockGetBotApiConfig = vi.fn(); -const mockBuildUpstreamUrl = vi.fn(); -const mockProxyToBotApi = vi.fn(); - -vi.mock("@/lib/bot-api-proxy", () => ({ - authorizeGuildAdmin: (...args: unknown[]) => mockAuthorizeGuildAdmin(...args), - getBotApiConfig: (...args: unknown[]) => mockGetBotApiConfig(...args), - buildUpstreamUrl: (...args: unknown[]) => mockBuildUpstreamUrl(...args), - proxyToBotApi: (...args: unknown[]) => mockProxyToBotApi(...args), -})); - -import { GET } from "@/app/api/guilds/[guildId]/tickets/route"; -import { NextResponse } from "next/server"; - -function createMockRequest( - guildId: string, - searchParams?: Record -): NextRequest { - let url = `http://localhost:3000/api/guilds/${guildId}/tickets`; - if (searchParams) { - const params = new URLSearchParams(searchParams); - url += `?${params.toString()}`; - } - return new NextRequest(new URL(url)); -} - -async function mockParams(guildId: string) { - return { guildId }; -} - -describe("GET /api/guilds/:guildId/tickets", () => { - beforeEach(() => { - vi.clearAllMocks(); - - // Default successful auth - mockAuthorizeGuildAdmin.mockResolvedValue(null); - - // Default successful config - mockGetBotApiConfig.mockReturnValue({ - baseUrl: "http://localhost:3001/api/v1", - secret: "test-secret", - }); - - // Default successful URL build - mockBuildUpstreamUrl.mockImplementation((baseUrl, path) => { - return new URL(path, baseUrl); - }); - - // Default successful proxy - mockProxyToBotApi.mockResolvedValue( - NextResponse.json({ - tickets: [], - total: 0, - page: 1, - limit: 25, - }) - ); - }); - - afterEach(() => { - vi.restoreAllMocks(); - }); - - // ─── Auth & Validation ─────────────────────────────────────── - - it("should return 400 when guildId is missing", async () => { - const request = createMockRequest(""); - const response = await GET(request, { params: mockParams("") }); - - expect(response.status).toBe(400); - const body = await response.json(); - expect(body.error).toBe("Missing guildId"); - }); - - it("should return error when authorization fails", async () => { - const authError = NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - mockAuthorizeGuildAdmin.mockResolvedValue(authError); - - const request = createMockRequest("guild1"); - const response = await GET(request, { params: mockParams("guild1") }); - - expect(response).toBe(authError); - expect(mockAuthorizeGuildAdmin).toHaveBeenCalledWith( - request, - "guild1", - "[api/guilds/:guildId/tickets]" - ); - }); - - it("should return error when bot API config is invalid", async () => { - const configError = NextResponse.json( - { error: "Bot API not configured" }, - { status: 500 } - ); - mockGetBotApiConfig.mockReturnValue(configError); - - const request = createMockRequest("guild1"); - const response = await GET(request, { params: mockParams("guild1") }); - - expect(response).toBe(configError); - }); - - it("should return error when upstream URL build fails", async () => { - const urlError = NextResponse.json({ error: "Invalid URL" }, { status: 500 }); - mockBuildUpstreamUrl.mockReturnValue(urlError); - - const request = createMockRequest("guild1"); - const response = await GET(request, { params: mockParams("guild1") }); - - expect(response).toBe(urlError); - }); - - // ─── Successful requests ───────────────────────────────────── - - it("should fetch tickets successfully", async () => { - const mockTickets = { - tickets: [ - { - id: 1, - guild_id: "guild1", - user_id: "user1", - topic: "Need help", - status: "open", - created_at: "2024-01-01T00:00:00Z", - }, - ], - total: 1, - page: 1, - limit: 25, - }; - - mockProxyToBotApi.mockResolvedValue(NextResponse.json(mockTickets)); - - const request = createMockRequest("guild1"); - const response = await GET(request, { params: mockParams("guild1") }); - - expect(response.status).toBe(200); - const body = await response.json(); - expect(body.tickets).toHaveLength(1); - expect(body.total).toBe(1); - }); - - it("should build correct upstream URL", async () => { - const request = createMockRequest("guild1"); - await GET(request, { params: mockParams("guild1") }); - - expect(mockBuildUpstreamUrl).toHaveBeenCalledWith( - "http://localhost:3001/api/v1", - "/guilds/guild1/tickets", - "[api/guilds/:guildId/tickets]" - ); - }); - - it("should call proxyToBotApi with correct parameters", async () => { - const upstreamUrl = new URL("http://localhost:3001/api/v1/guilds/guild1/tickets"); - mockBuildUpstreamUrl.mockReturnValue(upstreamUrl); - - const request = createMockRequest("guild1"); - await GET(request, { params: mockParams("guild1") }); - - expect(mockProxyToBotApi).toHaveBeenCalledWith( - upstreamUrl, - "test-secret", - "[api/guilds/:guildId/tickets]", - "Failed to fetch tickets" - ); - }); - - // ─── Query parameters ──────────────────────────────────────── - - it("should forward page parameter", async () => { - const upstreamUrl = new URL("http://localhost:3001/api/v1/guilds/guild1/tickets"); - mockBuildUpstreamUrl.mockReturnValue(upstreamUrl); - - const request = createMockRequest("guild1", { page: "2" }); - await GET(request, { params: mockParams("guild1") }); - - expect(mockProxyToBotApi).toHaveBeenCalled(); - const calledUrl = mockProxyToBotApi.mock.calls[0][0] as URL; - expect(calledUrl.searchParams.get("page")).toBe("2"); - }); - - it("should forward limit parameter", async () => { - const upstreamUrl = new URL("http://localhost:3001/api/v1/guilds/guild1/tickets"); - mockBuildUpstreamUrl.mockReturnValue(upstreamUrl); - - const request = createMockRequest("guild1", { limit: "50" }); - await GET(request, { params: mockParams("guild1") }); - - const calledUrl = mockProxyToBotApi.mock.calls[0][0] as URL; - expect(calledUrl.searchParams.get("limit")).toBe("50"); - }); - - it("should forward status parameter", async () => { - const upstreamUrl = new URL("http://localhost:3001/api/v1/guilds/guild1/tickets"); - mockBuildUpstreamUrl.mockReturnValue(upstreamUrl); - - const request = createMockRequest("guild1", { status: "closed" }); - await GET(request, { params: mockParams("guild1") }); - - const calledUrl = mockProxyToBotApi.mock.calls[0][0] as URL; - expect(calledUrl.searchParams.get("status")).toBe("closed"); - }); - - it("should forward user parameter", async () => { - const upstreamUrl = new URL("http://localhost:3001/api/v1/guilds/guild1/tickets"); - mockBuildUpstreamUrl.mockReturnValue(upstreamUrl); - - const request = createMockRequest("guild1", { user: "user123" }); - await GET(request, { params: mockParams("guild1") }); - - const calledUrl = mockProxyToBotApi.mock.calls[0][0] as URL; - expect(calledUrl.searchParams.get("user")).toBe("user123"); - }); - - it("should forward multiple parameters", async () => { - const upstreamUrl = new URL("http://localhost:3001/api/v1/guilds/guild1/tickets"); - mockBuildUpstreamUrl.mockReturnValue(upstreamUrl); - - const request = createMockRequest("guild1", { - page: "2", - limit: "10", - status: "open", - user: "user456", - }); - await GET(request, { params: mockParams("guild1") }); - - const calledUrl = mockProxyToBotApi.mock.calls[0][0] as URL; - expect(calledUrl.searchParams.get("page")).toBe("2"); - expect(calledUrl.searchParams.get("limit")).toBe("10"); - expect(calledUrl.searchParams.get("status")).toBe("open"); - expect(calledUrl.searchParams.get("user")).toBe("user456"); - }); - - it("should not forward unrecognized parameters", async () => { - const upstreamUrl = new URL("http://localhost:3001/api/v1/guilds/guild1/tickets"); - mockBuildUpstreamUrl.mockReturnValue(upstreamUrl); - - const request = createMockRequest("guild1", { invalidParam: "value" }); - await GET(request, { params: mockParams("guild1") }); - - const calledUrl = mockProxyToBotApi.mock.calls[0][0] as URL; - expect(calledUrl.searchParams.has("invalidParam")).toBe(false); - }); - - it("should omit null parameters", async () => { - const upstreamUrl = new URL("http://localhost:3001/api/v1/guilds/guild1/tickets"); - mockBuildUpstreamUrl.mockReturnValue(upstreamUrl); - - const request = createMockRequest("guild1"); - await GET(request, { params: mockParams("guild1") }); - - const calledUrl = mockProxyToBotApi.mock.calls[0][0] as URL; - // Only parameters explicitly set should be present - expect(calledUrl.searchParams.toString()).toBe(""); - }); - - // ─── Error handling ────────────────────────────────────────── - - it("should handle proxy errors gracefully", async () => { - mockProxyToBotApi.mockResolvedValue( - NextResponse.json({ error: "Upstream error" }, { status: 500 }) - ); - - const request = createMockRequest("guild1"); - const response = await GET(request, { params: mockParams("guild1") }); - - expect(response.status).toBe(500); - }); - - // ─── Guild ID encoding ─────────────────────────────────────── - - it("should properly encode guild ID in URL", async () => { - const guildIdWithSpecialChars = "guild/special"; - const request = createMockRequest(guildIdWithSpecialChars); - await GET(request, { params: mockParams(guildIdWithSpecialChars) }); - - expect(mockBuildUpstreamUrl).toHaveBeenCalledWith( - expect.any(String), - "/guilds/guild%2Fspecial/tickets", - expect.any(String) - ); - }); -}); \ No newline at end of file diff --git a/web/tests/api/guilds/[guildId]/tickets/stats/route.test.ts b/web/tests/api/guilds/[guildId]/tickets/stats/route.test.ts deleted file mode 100644 index fb4b5760..00000000 --- a/web/tests/api/guilds/[guildId]/tickets/stats/route.test.ts +++ /dev/null @@ -1,336 +0,0 @@ -/** - * Tests for web/src/app/api/guilds/[guildId]/tickets/stats/route.ts - * Covers GET endpoint for fetching ticket statistics - */ -import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { NextRequest, NextResponse } from "next/server"; - -// Mock bot-api-proxy module -const mockAuthorizeGuildAdmin = vi.fn(); -const mockGetBotApiConfig = vi.fn(); -const mockBuildUpstreamUrl = vi.fn(); -const mockProxyToBotApi = vi.fn(); - -vi.mock("@/lib/bot-api-proxy", () => ({ - authorizeGuildAdmin: (...args: unknown[]) => mockAuthorizeGuildAdmin(...args), - getBotApiConfig: (...args: unknown[]) => mockGetBotApiConfig(...args), - buildUpstreamUrl: (...args: unknown[]) => mockBuildUpstreamUrl(...args), - proxyToBotApi: (...args: unknown[]) => mockProxyToBotApi(...args), -})); - -import { GET } from "@/app/api/guilds/[guildId]/tickets/stats/route"; - -function createMockRequest(guildId: string): NextRequest { - const url = `http://localhost:3000/api/guilds/${guildId}/tickets/stats`; - return new NextRequest(new URL(url)); -} - -async function mockParams(guildId: string) { - return { guildId }; -} - -describe("GET /api/guilds/:guildId/tickets/stats", () => { - beforeEach(() => { - vi.clearAllMocks(); - - // Default successful auth - mockAuthorizeGuildAdmin.mockResolvedValue(null); - - // Default successful config - mockGetBotApiConfig.mockReturnValue({ - baseUrl: "http://localhost:3001/api/v1", - secret: "test-secret", - }); - - // Default successful URL build - mockBuildUpstreamUrl.mockImplementation((baseUrl, path) => { - return new URL(path, baseUrl); - }); - - // Default successful proxy with zero stats - mockProxyToBotApi.mockResolvedValue( - NextResponse.json({ - openCount: 0, - avgResolutionSeconds: 0, - ticketsThisWeek: 0, - }) - ); - }); - - afterEach(() => { - vi.restoreAllMocks(); - }); - - // ─── Validation ────────────────────────────────────────────── - - it("should return 400 when guildId is missing", async () => { - const request = createMockRequest(""); - const response = await GET(request, { params: mockParams("") }); - - expect(response.status).toBe(400); - const body = await response.json(); - expect(body.error).toBe("Missing guildId"); - }); - - // ─── Auth & Config ─────────────────────────────────────────── - - it("should return error when authorization fails", async () => { - const authError = NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - mockAuthorizeGuildAdmin.mockResolvedValue(authError); - - const request = createMockRequest("guild1"); - const response = await GET(request, { params: mockParams("guild1") }); - - expect(response).toBe(authError); - expect(mockAuthorizeGuildAdmin).toHaveBeenCalledWith( - request, - "guild1", - "[api/guilds/:guildId/tickets/stats]" - ); - }); - - it("should return error when bot API config is invalid", async () => { - const configError = NextResponse.json( - { error: "Bot API not configured" }, - { status: 500 } - ); - mockGetBotApiConfig.mockReturnValue(configError); - - const request = createMockRequest("guild1"); - const response = await GET(request, { params: mockParams("guild1") }); - - expect(response).toBe(configError); - }); - - it("should return error when upstream URL build fails", async () => { - const urlError = NextResponse.json({ error: "Invalid URL" }, { status: 500 }); - mockBuildUpstreamUrl.mockReturnValue(urlError); - - const request = createMockRequest("guild1"); - const response = await GET(request, { params: mockParams("guild1") }); - - expect(response).toBe(urlError); - }); - - // ─── Successful requests ───────────────────────────────────── - - it("should fetch ticket stats successfully", async () => { - const mockStats = { - openCount: 5, - avgResolutionSeconds: 3600, - ticketsThisWeek: 12, - }; - - mockProxyToBotApi.mockResolvedValue(NextResponse.json(mockStats)); - - const request = createMockRequest("guild1"); - const response = await GET(request, { params: mockParams("guild1") }); - - expect(response.status).toBe(200); - const body = await response.json(); - expect(body.openCount).toBe(5); - expect(body.avgResolutionSeconds).toBe(3600); - expect(body.ticketsThisWeek).toBe(12); - }); - - it("should build correct upstream URL", async () => { - const request = createMockRequest("guild1"); - await GET(request, { params: mockParams("guild1") }); - - expect(mockBuildUpstreamUrl).toHaveBeenCalledWith( - "http://localhost:3001/api/v1", - "/guilds/guild1/tickets/stats", - "[api/guilds/:guildId/tickets/stats]" - ); - }); - - it("should call proxyToBotApi with correct parameters", async () => { - const upstreamUrl = new URL("http://localhost:3001/api/v1/guilds/guild1/tickets/stats"); - mockBuildUpstreamUrl.mockReturnValue(upstreamUrl); - - const request = createMockRequest("guild1"); - await GET(request, { params: mockParams("guild1") }); - - expect(mockProxyToBotApi).toHaveBeenCalledWith( - upstreamUrl, - "test-secret", - "[api/guilds/:guildId/tickets/stats]", - "Failed to fetch ticket stats" - ); - }); - - // ─── Zero stats ────────────────────────────────────────────── - - it("should return zero stats for guild with no tickets", async () => { - const mockStats = { - openCount: 0, - avgResolutionSeconds: 0, - ticketsThisWeek: 0, - }; - - mockProxyToBotApi.mockResolvedValue(NextResponse.json(mockStats)); - - const request = createMockRequest("guild1"); - const response = await GET(request, { params: mockParams("guild1") }); - - expect(response.status).toBe(200); - const body = await response.json(); - expect(body.openCount).toBe(0); - expect(body.avgResolutionSeconds).toBe(0); - expect(body.ticketsThisWeek).toBe(0); - }); - - // ─── High-traffic scenarios ────────────────────────────────── - - it("should handle guilds with many open tickets", async () => { - const mockStats = { - openCount: 150, - avgResolutionSeconds: 7200, - ticketsThisWeek: 300, - }; - - mockProxyToBotApi.mockResolvedValue(NextResponse.json(mockStats)); - - const request = createMockRequest("guild1"); - const response = await GET(request, { params: mockParams("guild1") }); - - expect(response.status).toBe(200); - const body = await response.json(); - expect(body.openCount).toBe(150); - expect(body.ticketsThisWeek).toBe(300); - }); - - it("should handle very fast resolution times", async () => { - const mockStats = { - openCount: 2, - avgResolutionSeconds: 60, // 1 minute average - ticketsThisWeek: 50, - }; - - mockProxyToBotApi.mockResolvedValue(NextResponse.json(mockStats)); - - const request = createMockRequest("guild1"); - const response = await GET(request, { params: mockParams("guild1") }); - - expect(response.status).toBe(200); - const body = await response.json(); - expect(body.avgResolutionSeconds).toBe(60); - }); - - it("should handle very slow resolution times", async () => { - const mockStats = { - openCount: 10, - avgResolutionSeconds: 604800, // 1 week - ticketsThisWeek: 5, - }; - - mockProxyToBotApi.mockResolvedValue(NextResponse.json(mockStats)); - - const request = createMockRequest("guild1"); - const response = await GET(request, { params: mockParams("guild1") }); - - expect(response.status).toBe(200); - const body = await response.json(); - expect(body.avgResolutionSeconds).toBe(604800); - }); - - // ─── URL encoding ──────────────────────────────────────────── - - it("should properly encode guild ID in URL", async () => { - const guildIdWithSpecialChars = "guild/special"; - const request = createMockRequest(guildIdWithSpecialChars); - await GET(request, { params: mockParams(guildIdWithSpecialChars) }); - - expect(mockBuildUpstreamUrl).toHaveBeenCalledWith( - expect.any(String), - "/guilds/guild%2Fspecial/tickets/stats", - expect.any(String) - ); - }); - - // ─── Error responses ───────────────────────────────────────── - - it("should handle 500 from upstream", async () => { - mockProxyToBotApi.mockResolvedValue( - NextResponse.json({ error: "Failed to fetch ticket stats" }, { status: 500 }) - ); - - const request = createMockRequest("guild1"); - const response = await GET(request, { params: mockParams("guild1") }); - - expect(response.status).toBe(500); - const body = await response.json(); - expect(body.error).toBe("Failed to fetch ticket stats"); - }); - - // ─── Edge cases ────────────────────────────────────────────── - - it("should handle stats with partial data", async () => { - const mockStats = { - openCount: 3, - avgResolutionSeconds: 0, // No closed tickets yet - ticketsThisWeek: 3, - }; - - mockProxyToBotApi.mockResolvedValue(NextResponse.json(mockStats)); - - const request = createMockRequest("guild1"); - const response = await GET(request, { params: mockParams("guild1") }); - - expect(response.status).toBe(200); - const body = await response.json(); - expect(body.openCount).toBe(3); - expect(body.avgResolutionSeconds).toBe(0); - }); - - it("should handle stats for different guilds independently", async () => { - // First guild - mockProxyToBotApi.mockResolvedValueOnce( - NextResponse.json({ - openCount: 5, - avgResolutionSeconds: 1800, - ticketsThisWeek: 10, - }) - ); - - const request1 = createMockRequest("guild1"); - const response1 = await GET(request1, { params: mockParams("guild1") }); - const body1 = await response1.json(); - - expect(body1.openCount).toBe(5); - - // Second guild - mockProxyToBotApi.mockResolvedValueOnce( - NextResponse.json({ - openCount: 0, - avgResolutionSeconds: 0, - ticketsThisWeek: 0, - }) - ); - - const request2 = createMockRequest("guild2"); - const response2 = await GET(request2, { params: mockParams("guild2") }); - const body2 = await response2.json(); - - expect(body2.openCount).toBe(0); - }); - - // ─── Boundary values ───────────────────────────────────────── - - it("should handle maximum safe integer values", async () => { - const mockStats = { - openCount: Number.MAX_SAFE_INTEGER, - avgResolutionSeconds: Number.MAX_SAFE_INTEGER, - ticketsThisWeek: Number.MAX_SAFE_INTEGER, - }; - - mockProxyToBotApi.mockResolvedValue(NextResponse.json(mockStats)); - - const request = createMockRequest("guild1"); - const response = await GET(request, { params: mockParams("guild1") }); - - expect(response.status).toBe(200); - const body = await response.json(); - expect(body.openCount).toBe(Number.MAX_SAFE_INTEGER); - }); -}); \ No newline at end of file diff --git a/web/tests/components/log-viewer.test.tsx b/web/tests/components/log-viewer.test.tsx new file mode 100644 index 00000000..bf80a9fb --- /dev/null +++ b/web/tests/components/log-viewer.test.tsx @@ -0,0 +1,53 @@ +import { describe, it, expect, vi, beforeAll } from "vitest"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { LogViewer } from "@/components/dashboard/log-viewer"; +import type { LogEntry } from "@/lib/log-ws"; + +const logWithMeta: LogEntry = { + id: "log-1", + timestamp: "2026-01-01T12:00:00.000Z", + level: "info", + message: "hello world", + module: "test", + meta: { requestId: "abc123" }, +}; + +beforeAll(() => { + Object.defineProperty(HTMLElement.prototype, "scrollIntoView", { + configurable: true, + value: vi.fn(), + }); +}); + +describe("LogViewer keyboard activation", () => { + it("toggles once when pressing Space on metadata row button", async () => { + const user = userEvent.setup(); + + render( {}} />); + + const rowButton = screen.getByRole("button", { expanded: false }); + rowButton.focus(); + + await user.keyboard("[Space]"); + expect(rowButton).toHaveAttribute("aria-expanded", "true"); + + await user.keyboard("[Space]"); + expect(rowButton).toHaveAttribute("aria-expanded", "false"); + }); + + it("toggles once when pressing Enter on metadata row button", async () => { + const user = userEvent.setup(); + + render( {}} />); + + const rowButton = screen.getByRole("button", { expanded: false }); + rowButton.focus(); + + await user.keyboard("[Enter]"); + expect(rowButton).toHaveAttribute("aria-expanded", "true"); + + await user.keyboard("[Enter]"); + expect(rowButton).toHaveAttribute("aria-expanded", "false"); + }); +}); diff --git a/web/tests/lib/auth.test.ts b/web/tests/lib/auth.test.ts index ce99b628..be4f6ff9 100644 --- a/web/tests/lib/auth.test.ts +++ b/web/tests/lib/auth.test.ts @@ -138,19 +138,22 @@ describe("authOptions", () => { it("rejects default NEXTAUTH_SECRET placeholder", async () => { vi.resetModules(); process.env.NEXTAUTH_SECRET = "change-me-in-production"; - await expect(import("@/lib/auth")).rejects.toThrow("NEXTAUTH_SECRET"); + const { getAuthOptions } = await import("@/lib/auth"); + expect(() => getAuthOptions()).toThrow("NEXTAUTH_SECRET"); }); it("rejects NEXTAUTH_SECRET shorter than 32 chars", async () => { vi.resetModules(); process.env.NEXTAUTH_SECRET = "too-short"; - await expect(import("@/lib/auth")).rejects.toThrow("NEXTAUTH_SECRET"); + const { getAuthOptions } = await import("@/lib/auth"); + expect(() => getAuthOptions()).toThrow("NEXTAUTH_SECRET"); }); it("rejects the new CHANGE_ME placeholder in NEXTAUTH_SECRET", async () => { vi.resetModules(); process.env.NEXTAUTH_SECRET = "CHANGE_ME_generate_with_openssl_rand_base64_32"; - await expect(import("@/lib/auth")).rejects.toThrow("NEXTAUTH_SECRET"); + const { getAuthOptions } = await import("@/lib/auth"); + expect(() => getAuthOptions()).toThrow("NEXTAUTH_SECRET"); }); it("rejects missing DISCORD_CLIENT_ID", async () => { @@ -158,7 +161,8 @@ describe("authOptions", () => { delete process.env.DISCORD_CLIENT_ID; process.env.DISCORD_CLIENT_SECRET = "test-client-secret"; process.env.NEXTAUTH_SECRET = "a-valid-secret-that-is-at-least-32-characters-long"; - await expect(import("@/lib/auth")).rejects.toThrow("DISCORD_CLIENT_ID"); + const { getAuthOptions } = await import("@/lib/auth"); + expect(() => getAuthOptions()).toThrow("DISCORD_CLIENT_ID"); }); it("rejects missing DISCORD_CLIENT_SECRET", async () => { @@ -166,7 +170,8 @@ describe("authOptions", () => { process.env.DISCORD_CLIENT_ID = "test-client-id"; delete process.env.DISCORD_CLIENT_SECRET; process.env.NEXTAUTH_SECRET = "a-valid-secret-that-is-at-least-32-characters-long"; - await expect(import("@/lib/auth")).rejects.toThrow("DISCORD_CLIENT_SECRET"); + const { getAuthOptions } = await import("@/lib/auth"); + expect(() => getAuthOptions()).toThrow("DISCORD_CLIENT_SECRET"); }); }); @@ -275,6 +280,23 @@ describe("refreshDiscordToken", () => { expect(result.accessToken).toBe("old-token"); }); + it("returns RefreshTokenError when env validation fails", async () => { + vi.resetModules(); + process.env.DISCORD_CLIENT_ID = "test-client-id"; + process.env.DISCORD_CLIENT_SECRET = "test-client-secret"; + process.env.NEXTAUTH_SECRET = "too-short"; + + const { refreshDiscordToken } = await import("@/lib/auth"); + const result = await refreshDiscordToken({ + accessToken: "old-token", + refreshToken: "old-refresh", + }); + + expect(result.error).toBe("RefreshTokenError"); + expect(result.accessToken).toBe("old-token"); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + it("jwt callback skips refresh when no refresh token exists", async () => { const { authOptions } = await import("@/lib/auth"); const jwtCallback = authOptions.callbacks?.jwt;