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;