forked from
tranquil.farm/tranquil-pds
Our Personal Data Server from scratch!
1import { vi } from "vitest";
2import type { AppPassword, InviteCode, Session } from "../lib/api.ts";
3import { _testResetState, _testSetState } from "../lib/auth.svelte.ts";
4import { clearAllToasts, getToasts, toast } from "../lib/toast.svelte.ts";
5import {
6 unsafeAsAccessToken,
7 unsafeAsDid,
8 unsafeAsEmail,
9 unsafeAsHandle,
10 unsafeAsInviteCode,
11 unsafeAsISODateString,
12 unsafeAsRefreshToken,
13} from "../lib/types/branded.ts";
14
15function createMockIndexedDB() {
16 const stores: Map<string, Map<string, unknown>> = new Map();
17
18 return {
19 open: vi.fn((_name: string, _version?: number) => {
20 const createTransaction = (_storeName: string, _mode?: string) => {
21 const tx = {
22 objectStore: (name: string) => {
23 if (!stores.has(name)) {
24 stores.set(name, new Map());
25 }
26 const store = stores.get(name)!;
27 return {
28 put: (value: unknown, key: string) => {
29 store.set(key, value);
30 return { result: undefined };
31 },
32 get: (key: string) => ({
33 result: store.get(key),
34 }),
35 };
36 },
37 oncomplete: null as (() => void) | null,
38 onerror: null as (() => void) | null,
39 };
40 setTimeout(() => tx.oncomplete?.(), 0);
41 return tx;
42 };
43
44 const request = {
45 result: {
46 objectStoreNames: { contains: () => true },
47 createObjectStore: vi.fn(),
48 transaction: createTransaction,
49 close: vi.fn(),
50 },
51 error: null,
52 onsuccess: null as (() => void) | null,
53 onerror: null as (() => void) | null,
54 onupgradeneeded: null as (() => void) | null,
55 };
56
57 setTimeout(() => {
58 request.onupgradeneeded?.();
59 request.onsuccess?.();
60 }, 0);
61
62 return request;
63 }),
64 };
65}
66
67export function setupIndexedDBMock(): void {
68 (globalThis as unknown as { indexedDB: unknown }).indexedDB =
69 createMockIndexedDB();
70}
71
72const originalPushState = globalThis.history.pushState.bind(globalThis.history);
73const originalReplaceState = globalThis.history.replaceState.bind(
74 globalThis.history,
75);
76
77globalThis.history.pushState = (
78 data: unknown,
79 unused: string,
80 url?: string | URL | null,
81) => {
82 originalPushState(data, unused, url);
83 if (url) {
84 const urlStr = typeof url === "string" ? url : url.toString();
85 Object.defineProperty(globalThis.location, "pathname", {
86 value: urlStr.split("?")[0],
87 writable: true,
88 configurable: true,
89 });
90 }
91};
92
93globalThis.history.replaceState = (
94 data: unknown,
95 unused: string,
96 url?: string | URL | null,
97) => {
98 originalReplaceState(data, unused, url);
99 if (url) {
100 const urlStr = typeof url === "string" ? url : url.toString();
101 Object.defineProperty(globalThis.location, "pathname", {
102 value: urlStr.split("?")[0],
103 writable: true,
104 configurable: true,
105 });
106 }
107};
108
109export interface MockResponse {
110 ok: boolean;
111 status: number;
112 json: () => Promise<unknown>;
113}
114export type MockHandler = (
115 url: string,
116 options?: RequestInit,
117) => MockResponse | Promise<MockResponse>;
118const mockHandlers: Map<string, MockHandler> = new Map();
119export function mockEndpoint(endpoint: string, handler: MockHandler): void {
120 mockHandlers.set(endpoint, handler);
121}
122export function mockEndpointOnce(endpoint: string, handler: MockHandler): void {
123 const originalHandler = mockHandlers.get(endpoint);
124 mockHandlers.set(endpoint, (url, options) => {
125 mockHandlers.set(endpoint, originalHandler!);
126 return handler(url, options);
127 });
128}
129export function clearMocks(): void {
130 mockHandlers.clear();
131 _testResetState();
132 clearAllToasts();
133}
134
135export function getErrorToasts(): string[] {
136 return getToasts()
137 .filter((t) => t.type === "error")
138 .map((t) => t.message);
139}
140
141export { getToasts, toast };
142function extractEndpoint(url: string): string {
143 const match = url.match(/\/xrpc\/([^?]+)/);
144 if (match) return match[1];
145 const pathOnly = url.split("?")[0];
146 return pathOnly;
147}
148export function setupFetchMock(): void {
149 globalThis.fetch = vi.fn(
150 async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
151 const url = typeof input === "string" ? input : input.toString();
152 const endpoint = extractEndpoint(url);
153 const handler = mockHandlers.get(endpoint);
154 if (handler) {
155 const result = await handler(url, init);
156 return {
157 ok: result.ok,
158 status: result.status,
159 json: result.json,
160 text: async () => JSON.stringify(await result.json()),
161 headers: new Headers(),
162 redirected: false,
163 statusText: result.ok ? "OK" : "Error",
164 type: "basic",
165 url,
166 clone: () => ({ ...result }) as Response,
167 body: null,
168 bodyUsed: false,
169 arrayBuffer: () => Promise.resolve(new ArrayBuffer(0)),
170 blob: () => Promise.resolve(new Blob()),
171 formData: () => Promise.resolve(new FormData()),
172 } as Response;
173 }
174 return {
175 ok: false,
176 status: 404,
177 json: () =>
178 Promise.resolve({
179 error: "NotFound",
180 message: `No mock for ${endpoint}`,
181 }),
182 text: () =>
183 Promise.resolve(
184 JSON.stringify({
185 error: "NotFound",
186 message: `No mock for ${endpoint}`,
187 }),
188 ),
189 headers: new Headers(),
190 redirected: false,
191 statusText: "Not Found",
192 type: "basic",
193 url,
194 clone: function () {
195 return this;
196 },
197 body: null,
198 bodyUsed: false,
199 arrayBuffer: () => Promise.resolve(new ArrayBuffer(0)),
200 blob: () => Promise.resolve(new Blob()),
201 formData: () => Promise.resolve(new FormData()),
202 } as Response;
203 },
204 );
205}
206export function jsonResponse<T>(data: T, status = 200): MockResponse {
207 return {
208 ok: status >= 200 && status < 300,
209 status,
210 json: () => Promise.resolve(data),
211 };
212}
213export function errorResponse(
214 error: string,
215 message: string,
216 status = 400,
217): MockResponse {
218 return {
219 ok: false,
220 status,
221 json: () => Promise.resolve({ error, message }),
222 };
223}
224export const mockData = {
225 session: (overrides?: Partial<Session>): Session => {
226 const base = {
227 did: unsafeAsDid("did:web:test.tranquil.dev:u:testuser"),
228 handle: unsafeAsHandle("testuser.test.tranquil.dev"),
229 accessJwt: unsafeAsAccessToken("mock-access-jwt-token"),
230 refreshJwt: unsafeAsRefreshToken("mock-refresh-jwt-token"),
231 contactKind: "email" as const,
232 email: unsafeAsEmail("test@example.com"),
233 emailConfirmed: true,
234 accountKind: "active" as const,
235 isAdmin: false,
236 };
237 return { ...base, ...overrides } as Session;
238 },
239 appPassword: (overrides?: Partial<AppPassword>): AppPassword => ({
240 name: "Test App",
241 createdAt: unsafeAsISODateString(new Date().toISOString()),
242 ...overrides,
243 }),
244 inviteCode: (overrides?: Partial<InviteCode>): InviteCode => ({
245 code: unsafeAsInviteCode("test-invite-123"),
246 available: 1,
247 disabled: false,
248 forAccount: unsafeAsDid("did:web:test.tranquil.dev:u:testuser"),
249 createdBy: unsafeAsDid("did:web:test.tranquil.dev:u:testuser"),
250 createdAt: unsafeAsISODateString(new Date().toISOString()),
251 uses: [],
252 ...overrides,
253 }),
254 notificationPrefs: (overrides?: Record<string, unknown>) => ({
255 preferredChannel: "email",
256 email: "test@example.com",
257 discordId: null,
258 discordVerified: false,
259 telegramUsername: null,
260 telegramVerified: false,
261 signalNumber: null,
262 signalVerified: false,
263 ...overrides,
264 }),
265 describeServer: (overrides?: Record<string, unknown>) => ({
266 availableUserDomains: ["test.tranquil.dev"],
267 inviteCodeRequired: false,
268 links: {
269 privacyPolicy: "https://example.com/privacy",
270 termsOfService: "https://example.com/tos",
271 },
272 selfHostedDidWebEnabled: true,
273 availableCommsChannels: ["email", "discord", "telegram", "signal"],
274 ...overrides,
275 }),
276 describeRepo: (did: string) => ({
277 handle: "testuser.test.tranquil.dev",
278 did,
279 didDoc: {},
280 collections: [
281 "app.bsky.feed.post",
282 "app.bsky.feed.like",
283 "app.bsky.graph.follow",
284 ],
285 handleIsCorrect: true,
286 }),
287};
288export function setupDefaultMocks(): void {
289 setupFetchMock();
290 setupIndexedDBMock();
291 mockEndpoint(
292 "com.atproto.server.getSession",
293 () => jsonResponse(mockData.session()),
294 );
295 mockEndpoint("com.atproto.server.createSession", (_url, options) => {
296 const body = JSON.parse((options?.body as string) || "{}");
297 if (body.identifier && body.password === "correctpassword") {
298 return jsonResponse(
299 mockData.session({ handle: body.identifier.replace("@", "") }),
300 );
301 }
302 return errorResponse(
303 "AuthenticationRequired",
304 "Invalid identifier or password",
305 401,
306 );
307 });
308 mockEndpoint(
309 "com.atproto.server.refreshSession",
310 () => jsonResponse(mockData.session()),
311 );
312 mockEndpoint("com.atproto.server.deleteSession", () => jsonResponse({}));
313 mockEndpoint(
314 "com.atproto.server.listAppPasswords",
315 () => jsonResponse({ passwords: [mockData.appPassword()] }),
316 );
317 mockEndpoint("com.atproto.server.createAppPassword", (_url, options) => {
318 const body = JSON.parse((options?.body as string) || "{}");
319 return jsonResponse({
320 name: body.name,
321 password: "xxxx-xxxx-xxxx-xxxx",
322 createdAt: new Date().toISOString(),
323 });
324 });
325 mockEndpoint("com.atproto.server.revokeAppPassword", () => jsonResponse({}));
326 mockEndpoint(
327 "com.atproto.server.getAccountInviteCodes",
328 () => jsonResponse({ codes: [mockData.inviteCode()] }),
329 );
330 mockEndpoint(
331 "com.atproto.server.createInviteCode",
332 () => jsonResponse({ code: "new-invite-" + Date.now() }),
333 );
334 mockEndpoint(
335 "_account.getNotificationPrefs",
336 () => jsonResponse(mockData.notificationPrefs()),
337 );
338 mockEndpoint(
339 "_account.updateNotificationPrefs",
340 () => jsonResponse({ success: true }),
341 );
342 mockEndpoint(
343 "_account.getNotificationHistory",
344 () => jsonResponse({ notifications: [] }),
345 );
346 mockEndpoint(
347 "com.atproto.server.requestEmailUpdate",
348 () => jsonResponse({ tokenRequired: true }),
349 );
350 mockEndpoint("com.atproto.server.updateEmail", () => jsonResponse({}));
351 mockEndpoint("com.atproto.identity.updateHandle", () => jsonResponse({}));
352 mockEndpoint(
353 "com.atproto.server.requestAccountDelete",
354 () => jsonResponse({}),
355 );
356 mockEndpoint("com.atproto.server.deleteAccount", () => jsonResponse({}));
357 mockEndpoint(
358 "com.atproto.server.describeServer",
359 () => jsonResponse(mockData.describeServer()),
360 );
361 mockEndpoint("com.atproto.repo.describeRepo", (url) => {
362 const params = new URLSearchParams(url.split("?")[1]);
363 const repo = params.get("repo") || "did:web:test";
364 return jsonResponse(mockData.describeRepo(repo));
365 });
366 mockEndpoint(
367 "com.atproto.repo.listRecords",
368 () => jsonResponse({ records: [] }),
369 );
370 mockEndpoint(
371 "_backup.listBackups",
372 () => jsonResponse({ backups: [] }),
373 );
374}
375export function setupAuthenticatedUser(
376 sessionOverrides?: Partial<Session>,
377): Session {
378 const session = mockData.session(sessionOverrides);
379 _testSetState({
380 session,
381 loading: false,
382 error: null,
383 });
384 return session;
385}
386export function setupUnauthenticatedUser(): void {
387 _testSetState({
388 session: null,
389 loading: false,
390 error: null,
391 });
392}