1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
|
/**
* @vitest-environment jsdom
*/
import "fake-indexeddb/auto";
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { Router } from "wouter";
import { memoryLocation } from "wouter/memory-location";
import { App } from "./App";
import { apiClient } from "./api/client";
import { AuthProvider, SyncProvider } from "./stores";
vi.mock("./api/client", () => ({
apiClient: {
login: vi.fn(),
logout: vi.fn(),
isAuthenticated: vi.fn(),
getTokens: vi.fn(),
getAuthHeader: vi.fn(),
onSessionExpired: vi.fn(() => vi.fn()),
rpc: {
api: {
decks: {
$get: vi.fn(),
},
},
},
},
ApiClientError: class ApiClientError extends Error {
constructor(
message: string,
public status: number,
public code?: string,
) {
super(message);
this.name = "ApiClientError";
}
},
}));
// Helper to create mock responses compatible with Hono's ClientResponse
function mockResponse(data: {
ok: boolean;
status?: number;
// biome-ignore lint/suspicious/noExplicitAny: Test helper needs flexible typing
json: () => Promise<any>;
}) {
return data as unknown as Awaited<
ReturnType<typeof apiClient.rpc.api.decks.$get>
>;
}
function renderWithRouter(path: string) {
const { hook } = memoryLocation({ path, static: true });
return render(
<Router hook={hook}>
<AuthProvider>
<SyncProvider>
<App />
</SyncProvider>
</AuthProvider>
</Router>,
);
}
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(apiClient.getTokens).mockReturnValue(null);
vi.mocked(apiClient.isAuthenticated).mockReturnValue(false);
});
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
describe("App routing", () => {
describe("when authenticated", () => {
beforeEach(() => {
vi.mocked(apiClient.getTokens).mockReturnValue({
accessToken: "access-token",
refreshToken: "refresh-token",
});
vi.mocked(apiClient.isAuthenticated).mockReturnValue(true);
vi.mocked(apiClient.getAuthHeader).mockReturnValue({
Authorization: "Bearer access-token",
});
vi.mocked(apiClient.rpc.api.decks.$get).mockResolvedValue(
mockResponse({
ok: true,
json: async () => ({ decks: [] }),
}),
);
});
it("renders home page at /", () => {
renderWithRouter("/");
expect(screen.getByRole("heading", { name: "Kioku" })).toBeDefined();
expect(screen.getByRole("heading", { name: "Your Decks" })).toBeDefined();
});
});
describe("when not authenticated", () => {
beforeEach(() => {
vi.mocked(apiClient.getTokens).mockReturnValue(null);
vi.mocked(apiClient.isAuthenticated).mockReturnValue(false);
});
it("redirects to login when accessing / without authentication", () => {
renderWithRouter("/");
// Should not render home page content
expect(screen.queryByRole("heading", { name: "Kioku" })).toBeNull();
});
});
it("renders login page at /login", () => {
renderWithRouter("/login");
expect(screen.getByRole("heading", { name: "Kioku" })).toBeDefined();
expect(screen.getByRole("heading", { name: "Welcome back" })).toBeDefined();
});
it("renders 404 page for unknown routes", () => {
renderWithRouter("/unknown-route");
expect(screen.getByRole("heading", { name: "404" })).toBeDefined();
expect(
screen.getByRole("heading", { name: "Page Not Found" }),
).toBeDefined();
expect(screen.getByRole("link", { name: /Go Home/i })).toBeDefined();
});
});
|