Our Personal Data Server from scratch!
at main 392 lines 12 kB view raw
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}