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