Skip to content

Commit d24e2f4

Browse files
committed
test: comprehensive tests for refresh tokens, rate limits, proxy, mocks
- Test refreshDiscordToken: success, failure, token rotation, no refresh token (issue #15) - Test fetchWithRateLimit: 429 response, retry-after parsing, max retries exhaustion (issue #16) - Test proxy: actual redirect behavior for unauthenticated requests, passthrough for valid tokens, callbackUrl preservation (issue #17) - Fix mock types: complete Account/Token objects with all required fields (issue #18) - Update guilds API test to use getToken() instead of session.accessToken - Update landing page tests for conditional invite button rendering - Update header/dashboard-shell/server-selector tests for refactored components - Add health check endpoint test - All 82 tests passing, lint clean, build succeeds
1 parent 3508d85 commit d24e2f4

11 files changed

Lines changed: 559 additions & 178 deletions

File tree

web/src/lib/auth.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,10 @@ const DISCORD_SCOPES = "identify guilds email";
3434
/**
3535
* Refresh a Discord OAuth2 access token using the refresh token.
3636
* Returns updated token fields or the original token with an error flag.
37+
*
38+
* Exported for testing; not intended for direct use outside auth callbacks.
3739
*/
38-
async function refreshDiscordToken(token: Record<string, unknown>): Promise<Record<string, unknown>> {
40+
export async function refreshDiscordToken(token: Record<string, unknown>): Promise<Record<string, unknown>> {
3941
const params = new URLSearchParams({
4042
client_id: process.env.DISCORD_CLIENT_ID ?? "",
4143
client_secret: process.env.DISCORD_CLIENT_SECRET ?? "",

web/tests/api/guilds.test.ts

Lines changed: 36 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,7 @@
11
import { describe, it, expect, vi, beforeEach } from "vitest";
2+
import { NextRequest } from "next/server";
23

3-
// Mock next-auth
4-
vi.mock("next-auth", () => ({
5-
default: vi.fn(),
6-
}));
7-
4+
// Mock next-auth/providers/discord
85
vi.mock("next-auth/providers/discord", () => ({
96
default: vi.fn((config: Record<string, unknown>) => ({
107
id: "discord",
@@ -14,78 +11,87 @@ vi.mock("next-auth/providers/discord", () => ({
1411
})),
1512
}));
1613

17-
// Mock getServerSession
18-
const mockGetServerSession = vi.fn();
19-
vi.mock("next-auth", async () => {
20-
return {
21-
default: vi.fn(),
22-
getServerSession: (...args: unknown[]) => mockGetServerSession(...args),
23-
};
24-
});
14+
// Mock getToken from next-auth/jwt (used in the new API route)
15+
const mockGetToken = vi.fn();
16+
vi.mock("next-auth/jwt", () => ({
17+
getToken: (...args: unknown[]) => mockGetToken(...args),
18+
}));
2519

2620
// Mock discord lib
2721
const mockGetMutualGuilds = vi.fn();
2822
vi.mock("@/lib/discord", () => ({
2923
getMutualGuilds: (...args: unknown[]) => mockGetMutualGuilds(...args),
3024
}));
3125

26+
function createMockRequest(url = "http://localhost:3000/api/guilds"): NextRequest {
27+
return new NextRequest(new URL(url));
28+
}
29+
3230
describe("GET /api/guilds", () => {
3331
beforeEach(() => {
3432
vi.clearAllMocks();
33+
process.env.NEXTAUTH_SECRET = "a-valid-secret-that-is-at-least-32-characters-long";
3534
});
3635

37-
it("returns 401 when not authenticated", async () => {
38-
mockGetServerSession.mockResolvedValue(null);
36+
it("returns 401 when no token exists", async () => {
37+
mockGetToken.mockResolvedValue(null);
3938

4039
const { GET } = await import("@/app/api/guilds/route");
41-
const response = await GET();
40+
const response = await GET(createMockRequest());
4241

4342
expect(response.status).toBe(401);
4443
const body = await response.json();
4544
expect(body.error).toBe("Unauthorized");
4645
});
4746

48-
it("returns 401 when session has no access token", async () => {
49-
mockGetServerSession.mockResolvedValue({
50-
user: { name: "Test" },
47+
it("returns 401 when token has no access token", async () => {
48+
mockGetToken.mockResolvedValue({
49+
sub: "123",
50+
id: "user-123",
5151
// No accessToken
5252
});
5353

5454
const { GET } = await import("@/app/api/guilds/route");
55-
const response = await GET();
55+
const response = await GET(createMockRequest());
5656

5757
expect(response.status).toBe(401);
5858
});
5959

60-
it("returns guilds when authenticated", async () => {
60+
it("returns guilds when authenticated with valid token", async () => {
6161
const mockGuilds = [
6262
{ id: "1", name: "Server 1", icon: null, botPresent: true },
6363
];
6464

65-
mockGetServerSession.mockResolvedValue({
66-
user: { name: "Test" },
67-
accessToken: "valid-token",
65+
mockGetToken.mockResolvedValue({
66+
sub: "123",
67+
accessToken: "valid-discord-token",
68+
refreshToken: "refresh-token",
69+
accessTokenExpires: Date.now() + 60_000,
70+
id: "discord-user-123",
6871
});
6972
mockGetMutualGuilds.mockResolvedValue(mockGuilds);
7073

7174
const { GET } = await import("@/app/api/guilds/route");
72-
const response = await GET();
75+
const response = await GET(createMockRequest());
7376

7477
expect(response.status).toBe(200);
7578
const body = await response.json();
7679
expect(body).toEqual(mockGuilds);
77-
expect(mockGetMutualGuilds).toHaveBeenCalledWith("valid-token");
80+
expect(mockGetMutualGuilds).toHaveBeenCalledWith("valid-discord-token");
7881
});
7982

8083
it("returns 500 on discord API error", async () => {
81-
mockGetServerSession.mockResolvedValue({
82-
user: { name: "Test" },
83-
accessToken: "valid-token",
84+
mockGetToken.mockResolvedValue({
85+
sub: "123",
86+
accessToken: "valid-discord-token",
87+
refreshToken: "refresh-token",
88+
accessTokenExpires: Date.now() + 60_000,
89+
id: "discord-user-123",
8490
});
8591
mockGetMutualGuilds.mockRejectedValue(new Error("Discord API error"));
8692

8793
const { GET } = await import("@/app/api/guilds/route");
88-
const response = await GET();
94+
const response = await GET(createMockRequest());
8995

9096
expect(response.status).toBe(500);
9197
const body = await response.json();

web/tests/api/health.test.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { describe, it, expect } from "vitest";
2+
import { GET } from "@/app/api/health/route";
3+
4+
describe("GET /api/health", () => {
5+
it("returns 200 with status ok", async () => {
6+
const response = await GET();
7+
expect(response.status).toBe(200);
8+
9+
const body = await response.json();
10+
expect(body.status).toBe("ok");
11+
expect(typeof body.timestamp).toBe("string");
12+
});
13+
});

web/tests/app/landing.test.tsx

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { describe, it, expect } from "vitest";
1+
import { describe, it, expect, beforeEach } from "vitest";
22
import { render, screen } from "@testing-library/react";
33
import LandingPage from "@/app/page";
44

@@ -19,10 +19,22 @@ describe("LandingPage", () => {
1919
expect(screen.getByText("Web Dashboard")).toBeDefined();
2020
});
2121

22-
it("renders sign in and add to server buttons", () => {
22+
it("renders sign in button", () => {
2323
render(<LandingPage />);
2424
expect(screen.getByText("Sign In")).toBeDefined();
25+
});
26+
27+
it("hides Add to Server button when CLIENT_ID is not set", () => {
28+
delete process.env.NEXT_PUBLIC_DISCORD_CLIENT_ID;
29+
render(<LandingPage />);
30+
expect(screen.queryByText("Add to Server")).toBeNull();
31+
});
32+
33+
it("shows Add to Server buttons when CLIENT_ID is set", () => {
34+
process.env.NEXT_PUBLIC_DISCORD_CLIENT_ID = "test-client-id";
35+
render(<LandingPage />);
2536
expect(screen.getAllByText("Add to Server").length).toBeGreaterThan(0);
37+
delete process.env.NEXT_PUBLIC_DISCORD_CLIENT_ID;
2638
});
2739

2840
it("renders footer with links", () => {
Lines changed: 5 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -1,65 +1,19 @@
11
import { describe, it, expect, vi } from "vitest";
22
import { render, screen } from "@testing-library/react";
33

4-
// Mock child components
4+
// Mock child components — DashboardShell is now a server component
55
vi.mock("@/components/layout/header", () => ({
6-
Header: ({ onMenuClick }: { onMenuClick: () => void }) => (
7-
<header data-testid="header">
8-
<button onClick={onMenuClick} data-testid="menu-btn">
9-
Menu
10-
</button>
11-
</header>
12-
),
6+
Header: () => <header data-testid="header">Header</header>,
137
}));
148

159
vi.mock("@/components/layout/sidebar", () => ({
16-
Sidebar: ({ onNavClick }: { onNavClick?: () => void }) => (
17-
<nav data-testid="sidebar" onClick={onNavClick}>
18-
Sidebar
19-
</nav>
20-
),
10+
Sidebar: () => <nav data-testid="sidebar">Sidebar</nav>,
2111
}));
2212

2313
vi.mock("@/components/layout/server-selector", () => ({
2414
ServerSelector: () => <div data-testid="server-selector">Servers</div>,
2515
}));
2616

27-
// Mock radix dialog for Sheet
28-
vi.mock("@radix-ui/react-dialog", () => {
29-
const React = require("react");
30-
return {
31-
Root: ({ children, open }: { children: React.ReactNode; open?: boolean }) => (
32-
<div data-testid="sheet-root" data-open={open}>
33-
{children}
34-
</div>
35-
),
36-
Trigger: ({ children }: { children: React.ReactNode }) => children,
37-
Portal: ({ children }: { children: React.ReactNode }) => children,
38-
Overlay: React.forwardRef((_: unknown, ref: React.Ref<HTMLDivElement>) => (
39-
<div ref={ref} data-testid="sheet-overlay" />
40-
)),
41-
Content: React.forwardRef(
42-
({ children }: { children: React.ReactNode }, ref: React.Ref<HTMLDivElement>) => (
43-
<div ref={ref} data-testid="sheet-content">
44-
{children}
45-
</div>
46-
),
47-
),
48-
Close: React.forwardRef(
49-
({ children }: { children: React.ReactNode }, ref: React.Ref<HTMLButtonElement>) => (
50-
<button ref={ref} data-testid="sheet-close">
51-
{children}
52-
</button>
53-
),
54-
),
55-
Title: React.forwardRef(
56-
({ children }: { children: React.ReactNode }, ref: React.Ref<HTMLHeadingElement>) => (
57-
<h2 ref={ref}>{children}</h2>
58-
),
59-
),
60-
};
61-
});
62-
6317
import { DashboardShell } from "@/components/layout/dashboard-shell";
6418

6519
describe("DashboardShell", () => {
@@ -70,7 +24,7 @@ describe("DashboardShell", () => {
7024
</DashboardShell>,
7125
);
7226
expect(screen.getByTestId("header")).toBeDefined();
73-
expect(screen.getAllByTestId("sidebar").length).toBeGreaterThan(0);
27+
expect(screen.getByTestId("sidebar")).toBeDefined();
7428
expect(screen.getByTestId("content")).toBeDefined();
7529
});
7630

@@ -80,6 +34,6 @@ describe("DashboardShell", () => {
8034
<div>Content</div>
8135
</DashboardShell>,
8236
);
83-
expect(screen.getAllByTestId("server-selector").length).toBeGreaterThan(0);
37+
expect(screen.getByTestId("server-selector")).toBeDefined();
8438
});
8539
});

web/tests/components/layout/header.test.tsx

Lines changed: 15 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ vi.mock("next-auth/react", () => ({
66
useSession: () => ({
77
data: {
88
user: {
9+
id: "discord-user-123",
910
name: "TestUser",
1011
email: "test@example.com",
1112
image: "https://cdn.discordapp.com/avatars/123/abc.png",
@@ -16,28 +17,30 @@ vi.mock("next-auth/react", () => ({
1617
signOut: vi.fn(),
1718
}));
1819

20+
// Mock the MobileSidebar client component
21+
vi.mock("@/components/layout/mobile-sidebar", () => ({
22+
MobileSidebar: () => (
23+
<button data-testid="mobile-sidebar-toggle" aria-label="Toggle menu">
24+
Menu
25+
</button>
26+
),
27+
}));
28+
1929
import { Header } from "@/components/layout/header";
2030

2131
describe("Header", () => {
2232
it("renders the brand name", () => {
23-
render(<Header onMenuClick={vi.fn()} />);
33+
render(<Header />);
2434
expect(screen.getByText("Bill Bot Dashboard")).toBeDefined();
2535
});
2636

27-
it("renders the hamburger menu button", () => {
28-
render(<Header onMenuClick={vi.fn()} />);
29-
expect(screen.getByLabelText("Toggle menu")).toBeDefined();
30-
});
31-
32-
it("calls onMenuClick when hamburger is clicked", () => {
33-
const onMenuClick = vi.fn();
34-
render(<Header onMenuClick={onMenuClick} />);
35-
screen.getByLabelText("Toggle menu").click();
36-
expect(onMenuClick).toHaveBeenCalled();
37+
it("renders the mobile sidebar toggle", () => {
38+
render(<Header />);
39+
expect(screen.getByTestId("mobile-sidebar-toggle")).toBeDefined();
3740
});
3841

3942
it("renders user fallback avatar when authenticated", () => {
40-
render(<Header onMenuClick={vi.fn()} />);
43+
render(<Header />);
4144
// Radix Avatar shows fallback initially in jsdom
4245
expect(screen.getByText("T")).toBeDefined();
4346
});

web/tests/components/layout/server-selector.test.tsx

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,11 +54,23 @@ describe("ServerSelector", () => {
5454
});
5555
});
5656

57-
it("handles fetch errors gracefully", async () => {
57+
it("shows error state with retry button on fetch failure", async () => {
5858
global.fetch = vi.fn().mockRejectedValue(new Error("Network error"));
5959
render(<ServerSelector />);
6060
await waitFor(() => {
61-
expect(screen.getByText("No servers found")).toBeDefined();
61+
expect(screen.getByText("Failed to load servers")).toBeDefined();
62+
expect(screen.getByText("Retry")).toBeDefined();
63+
});
64+
});
65+
66+
it("shows error state on non-OK response", async () => {
67+
global.fetch = vi.fn().mockResolvedValue({
68+
ok: false,
69+
status: 500,
70+
});
71+
render(<ServerSelector />);
72+
await waitFor(() => {
73+
expect(screen.getByText("Failed to load servers")).toBeDefined();
6274
});
6375
});
6476
});

web/tests/components/providers.test.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,14 @@ vi.mock("next-auth/react", () => ({
66
SessionProvider: ({ children }: { children: React.ReactNode }) => (
77
<div data-testid="session-provider">{children}</div>
88
),
9+
useSession: () => ({ data: null, status: "unauthenticated" }),
10+
signIn: vi.fn(),
911
}));
1012

1113
import { Providers } from "@/components/providers";
1214

1315
describe("Providers", () => {
14-
it("wraps children in SessionProvider", () => {
16+
it("wraps children in SessionProvider with SessionGuard", () => {
1517
render(
1618
<Providers>
1719
<div data-testid="child">Hello</div>

0 commit comments

Comments
 (0)