Our Personal Data Server from scratch!
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

at fix/small-bugs 541 lines 16 kB view raw
1import { beforeEach, describe, expect, it } from "vitest"; 2import { 3 clearMigrationState, 4 getResumeInfo, 5 hasPendingMigration, 6 loadMigrationState, 7 saveMigrationState, 8 setError, 9 updateProgress, 10 updateStep, 11} from "../../lib/migration/storage.ts"; 12import type { 13 InboundMigrationState, 14 MigrationState, 15} from "../../lib/migration/types.ts"; 16 17interface OutboundMigrationState { 18 direction: "outbound"; 19 step: string; 20 localDid: string; 21 localHandle: string; 22 targetPdsUrl: string; 23 targetPdsDid: string; 24 targetHandle: string; 25 targetEmail: string; 26 targetPassword: string; 27 inviteCode: string; 28 targetAccessToken: string | null; 29 targetRefreshToken: string | null; 30 serviceAuthToken: string | null; 31 plcToken: string; 32 progress: { 33 repoExported: boolean; 34 repoImported: boolean; 35 blobsTotal: number; 36 blobsMigrated: number; 37 blobsFailed: string[]; 38 prefsMigrated: boolean; 39 plcSigned: boolean; 40 activated: boolean; 41 deactivated: boolean; 42 currentOperation: string; 43 }; 44 error: string | null; 45 targetServerInfo: unknown; 46} 47 48const STORAGE_KEY = "tranquil_migration_state"; 49const DPOP_KEY_STORAGE = "migration_dpop_key"; 50 51function createInboundState( 52 overrides?: Partial<InboundMigrationState>, 53): InboundMigrationState { 54 return { 55 direction: "inbound", 56 step: "welcome", 57 sourcePdsUrl: "https://bsky.social", 58 sourceDid: "did:plc:abc123", 59 sourceHandle: "alice.bsky.social", 60 targetHandle: "alice.example.com", 61 targetEmail: "alice@example.com", 62 targetPassword: "password123", 63 inviteCode: "", 64 sourceAccessToken: null, 65 sourceRefreshToken: null, 66 serviceAuthToken: null, 67 emailVerifyToken: "", 68 plcToken: "", 69 progress: { 70 repoExported: false, 71 repoImported: false, 72 blobsTotal: 0, 73 blobsMigrated: 0, 74 blobsFailed: [], 75 prefsMigrated: false, 76 plcSigned: false, 77 activated: false, 78 deactivated: false, 79 currentOperation: "", 80 }, 81 error: null, 82 targetVerificationMethod: null, 83 authMethod: "password", 84 passkeySetupToken: null, 85 oauthCodeVerifier: null, 86 localAccessToken: null, 87 generatedAppPassword: null, 88 generatedAppPasswordName: null, 89 ...overrides, 90 }; 91} 92 93function createOutboundState( 94 overrides?: Partial<OutboundMigrationState>, 95): OutboundMigrationState { 96 return { 97 direction: "outbound", 98 step: "welcome", 99 localDid: "did:plc:xyz789", 100 localHandle: "bob.example.com", 101 targetPdsUrl: "https://new-pds.com", 102 targetPdsDid: "did:web:new-pds.com", 103 targetHandle: "bob.new-pds.com", 104 targetEmail: "bob@new-pds.com", 105 targetPassword: "password456", 106 inviteCode: "", 107 targetAccessToken: null, 108 targetRefreshToken: null, 109 serviceAuthToken: null, 110 plcToken: "", 111 progress: { 112 repoExported: false, 113 repoImported: false, 114 blobsTotal: 0, 115 blobsMigrated: 0, 116 blobsFailed: [], 117 prefsMigrated: false, 118 plcSigned: false, 119 activated: false, 120 deactivated: false, 121 currentOperation: "", 122 }, 123 error: null, 124 targetServerInfo: null, 125 ...overrides, 126 }; 127} 128 129describe("migration/storage", () => { 130 beforeEach(() => { 131 localStorage.removeItem(STORAGE_KEY); 132 localStorage.removeItem(DPOP_KEY_STORAGE); 133 }); 134 135 describe("saveMigrationState", () => { 136 it("saves inbound migration state to localStorage", () => { 137 const state = createInboundState({ 138 step: "migrating", 139 progress: { 140 repoExported: true, 141 repoImported: false, 142 blobsTotal: 10, 143 blobsMigrated: 5, 144 blobsFailed: [], 145 prefsMigrated: false, 146 plcSigned: false, 147 activated: false, 148 deactivated: false, 149 currentOperation: "Migrating blobs...", 150 }, 151 }); 152 153 saveMigrationState(state); 154 155 const stored = JSON.parse(localStorage.getItem(STORAGE_KEY)!); 156 expect(stored.version).toBe(1); 157 expect(stored.direction).toBe("inbound"); 158 expect(stored.step).toBe("migrating"); 159 expect(stored.sourcePdsUrl).toBe("https://bsky.social"); 160 expect(stored.sourceDid).toBe("did:plc:abc123"); 161 expect(stored.sourceHandle).toBe("alice.bsky.social"); 162 expect(stored.targetHandle).toBe("alice.example.com"); 163 expect(stored.targetEmail).toBe("alice@example.com"); 164 expect(stored.progress.repoExported).toBe(true); 165 expect(stored.progress.blobsMigrated).toBe(5); 166 expect(stored.startedAt).toBeDefined(); 167 expect(new Date(stored.startedAt).getTime()).not.toBeNaN(); 168 }); 169 170 it("saves outbound migration state to localStorage", () => { 171 const state = createOutboundState({ 172 step: "review", 173 }); 174 175 saveMigrationState(state as unknown as MigrationState); 176 177 const stored = JSON.parse(localStorage.getItem(STORAGE_KEY)!); 178 expect(stored.version).toBe(1); 179 expect(stored.direction).toBe("outbound"); 180 expect(stored.step).toBe("review"); 181 expect(stored.targetHandle).toBe("bob.new-pds.com"); 182 expect(stored.targetEmail).toBe("bob@new-pds.com"); 183 }); 184 185 it("saves authMethod for inbound migrations", () => { 186 const state = createInboundState({ authMethod: "passkey" }); 187 188 saveMigrationState(state); 189 190 const stored = JSON.parse(localStorage.getItem(STORAGE_KEY)!); 191 expect(stored.authMethod).toBe("passkey"); 192 }); 193 194 it("saves passkeySetupToken when present", () => { 195 const state = createInboundState({ 196 authMethod: "passkey", 197 passkeySetupToken: "setup-token-123", 198 }); 199 200 saveMigrationState(state); 201 202 const stored = JSON.parse(localStorage.getItem(STORAGE_KEY)!); 203 expect(stored.passkeySetupToken).toBe("setup-token-123"); 204 }); 205 206 it("saves error information", () => { 207 const state = createInboundState({ 208 step: "error", 209 error: "Connection failed", 210 }); 211 212 saveMigrationState(state); 213 214 const stored = JSON.parse(localStorage.getItem(STORAGE_KEY)!); 215 expect(stored.lastError).toBe("Connection failed"); 216 expect(stored.lastErrorStep).toBe("error"); 217 }); 218 }); 219 220 describe("loadMigrationState", () => { 221 it("returns null when no state is stored", () => { 222 expect(loadMigrationState()).toBeNull(); 223 }); 224 225 it("loads valid migration state", () => { 226 const state = createInboundState({ step: "migrating" }); 227 saveMigrationState(state); 228 229 const loaded = loadMigrationState(); 230 231 expect(loaded).not.toBeNull(); 232 expect(loaded!.direction).toBe("inbound"); 233 expect(loaded!.step).toBe("migrating"); 234 expect(loaded!.sourceHandle).toBe("alice.bsky.social"); 235 }); 236 237 it("clears and returns null for incompatible version", () => { 238 localStorage.setItem( 239 STORAGE_KEY, 240 JSON.stringify({ 241 version: 999, 242 direction: "inbound", 243 step: "welcome", 244 }), 245 ); 246 247 const loaded = loadMigrationState(); 248 249 expect(loaded).toBeNull(); 250 expect(localStorage.getItem(STORAGE_KEY)).toBeNull(); 251 }); 252 253 it("clears and returns null for expired state (> 24 hours)", () => { 254 const expiredState = { 255 version: 1, 256 direction: "inbound", 257 step: "welcome", 258 startedAt: new Date(Date.now() - 25 * 60 * 60 * 1000).toISOString(), 259 sourcePdsUrl: "https://bsky.social", 260 targetPdsUrl: "http://localhost:3000", 261 sourceDid: "did:plc:abc123", 262 sourceHandle: "alice.bsky.social", 263 targetHandle: "alice.example.com", 264 targetEmail: "alice@example.com", 265 progress: { 266 repoExported: false, 267 repoImported: false, 268 blobsTotal: 0, 269 blobsMigrated: 0, 270 prefsMigrated: false, 271 plcSigned: false, 272 }, 273 }; 274 localStorage.setItem(STORAGE_KEY, JSON.stringify(expiredState)); 275 276 const loaded = loadMigrationState(); 277 278 expect(loaded).toBeNull(); 279 expect(localStorage.getItem(STORAGE_KEY)).toBeNull(); 280 }); 281 282 it("returns state that is not yet expired (< 24 hours)", () => { 283 const recentState = { 284 version: 1, 285 direction: "inbound", 286 step: "review", 287 startedAt: new Date(Date.now() - 23 * 60 * 60 * 1000).toISOString(), 288 sourcePdsUrl: "https://bsky.social", 289 targetPdsUrl: "http://localhost:3000", 290 sourceDid: "did:plc:abc123", 291 sourceHandle: "alice.bsky.social", 292 targetHandle: "alice.example.com", 293 targetEmail: "alice@example.com", 294 progress: { 295 repoExported: false, 296 repoImported: false, 297 blobsTotal: 0, 298 blobsMigrated: 0, 299 prefsMigrated: false, 300 plcSigned: false, 301 }, 302 }; 303 localStorage.setItem(STORAGE_KEY, JSON.stringify(recentState)); 304 305 const loaded = loadMigrationState(); 306 307 expect(loaded).not.toBeNull(); 308 expect(loaded!.step).toBe("review"); 309 }); 310 311 it("clears and returns null for invalid JSON", () => { 312 localStorage.setItem(STORAGE_KEY, "not-valid-json"); 313 314 const loaded = loadMigrationState(); 315 316 expect(loaded).toBeNull(); 317 expect(localStorage.getItem(STORAGE_KEY)).toBeNull(); 318 }); 319 }); 320 321 describe("clearMigrationState", () => { 322 it("removes migration state from localStorage", () => { 323 const state = createInboundState(); 324 saveMigrationState(state); 325 expect(localStorage.getItem(STORAGE_KEY)).not.toBeNull(); 326 327 clearMigrationState(); 328 329 expect(localStorage.getItem(STORAGE_KEY)).toBeNull(); 330 }); 331 332 it("also removes DPoP key", () => { 333 localStorage.setItem(DPOP_KEY_STORAGE, "some-dpop-key"); 334 const state = createInboundState(); 335 saveMigrationState(state); 336 337 clearMigrationState(); 338 339 expect(localStorage.getItem(DPOP_KEY_STORAGE)).toBeNull(); 340 }); 341 342 it("does not throw when nothing to clear", () => { 343 expect(() => clearMigrationState()).not.toThrow(); 344 }); 345 }); 346 347 describe("hasPendingMigration", () => { 348 it("returns false when no migration state exists", () => { 349 expect(hasPendingMigration()).toBe(false); 350 }); 351 352 it("returns true when valid migration state exists", () => { 353 const state = createInboundState(); 354 saveMigrationState(state); 355 356 expect(hasPendingMigration()).toBe(true); 357 }); 358 359 it("returns false when state is expired", () => { 360 const expiredState = { 361 version: 1, 362 direction: "inbound", 363 step: "welcome", 364 startedAt: new Date(Date.now() - 25 * 60 * 60 * 1000).toISOString(), 365 sourcePdsUrl: "https://bsky.social", 366 targetPdsUrl: "http://localhost:3000", 367 sourceDid: "did:plc:abc123", 368 sourceHandle: "alice.bsky.social", 369 targetHandle: "alice.example.com", 370 targetEmail: "alice@example.com", 371 progress: { 372 repoExported: false, 373 repoImported: false, 374 blobsTotal: 0, 375 blobsMigrated: 0, 376 prefsMigrated: false, 377 plcSigned: false, 378 }, 379 }; 380 localStorage.setItem(STORAGE_KEY, JSON.stringify(expiredState)); 381 382 expect(hasPendingMigration()).toBe(false); 383 }); 384 }); 385 386 describe("getResumeInfo", () => { 387 it("returns null when no migration state exists", () => { 388 expect(getResumeInfo()).toBeNull(); 389 }); 390 391 it("returns resume info for inbound migration", () => { 392 const state = createInboundState({ 393 step: "migrating", 394 progress: { 395 repoExported: true, 396 repoImported: true, 397 blobsTotal: 10, 398 blobsMigrated: 5, 399 blobsFailed: [], 400 prefsMigrated: false, 401 plcSigned: false, 402 activated: false, 403 deactivated: false, 404 currentOperation: "", 405 }, 406 }); 407 saveMigrationState(state); 408 409 const info = getResumeInfo(); 410 411 expect(info).not.toBeNull(); 412 expect(info!.direction).toBe("inbound"); 413 expect(info!.sourceHandle).toBe("alice.bsky.social"); 414 expect(info!.targetHandle).toBe("alice.example.com"); 415 expect(info!.progressSummary).toContain("repo exported"); 416 expect(info!.progressSummary).toContain("repo imported"); 417 expect(info!.progressSummary).toContain("5/10 blobs"); 418 }); 419 420 it("returns 'just started' when no progress made", () => { 421 const state = createInboundState({ step: "welcome" }); 422 saveMigrationState(state); 423 424 const info = getResumeInfo(); 425 426 expect(info!.progressSummary).toBe("just started"); 427 }); 428 429 it("includes authMethod for inbound migrations", () => { 430 const state = createInboundState({ authMethod: "passkey" }); 431 saveMigrationState(state); 432 433 const info = getResumeInfo(); 434 435 expect(info!.authMethod).toBe("passkey"); 436 }); 437 438 it("includes all completed progress items", () => { 439 const state = createInboundState({ 440 step: "finalizing", 441 progress: { 442 repoExported: true, 443 repoImported: true, 444 blobsTotal: 10, 445 blobsMigrated: 10, 446 blobsFailed: [], 447 prefsMigrated: true, 448 plcSigned: true, 449 activated: false, 450 deactivated: false, 451 currentOperation: "", 452 }, 453 }); 454 saveMigrationState(state); 455 456 const info = getResumeInfo(); 457 458 expect(info!.progressSummary).toContain("repo exported"); 459 expect(info!.progressSummary).toContain("repo imported"); 460 expect(info!.progressSummary).toContain("preferences migrated"); 461 expect(info!.progressSummary).toContain("PLC signed"); 462 }); 463 }); 464 465 describe("updateProgress", () => { 466 it("updates progress fields in stored state", () => { 467 const state = createInboundState(); 468 saveMigrationState(state); 469 470 updateProgress({ repoExported: true, blobsTotal: 50 }); 471 472 const loaded = loadMigrationState(); 473 expect(loaded!.progress.repoExported).toBe(true); 474 expect(loaded!.progress.blobsTotal).toBe(50); 475 }); 476 477 it("preserves other progress fields", () => { 478 const state = createInboundState({ 479 progress: { 480 repoExported: true, 481 repoImported: false, 482 blobsTotal: 10, 483 blobsMigrated: 0, 484 blobsFailed: [], 485 prefsMigrated: false, 486 plcSigned: false, 487 activated: false, 488 deactivated: false, 489 currentOperation: "", 490 }, 491 }); 492 saveMigrationState(state); 493 494 updateProgress({ repoImported: true }); 495 496 const loaded = loadMigrationState(); 497 expect(loaded!.progress.repoExported).toBe(true); 498 expect(loaded!.progress.repoImported).toBe(true); 499 }); 500 501 it("does nothing when no state exists", () => { 502 expect(() => updateProgress({ repoExported: true })).not.toThrow(); 503 expect(localStorage.getItem(STORAGE_KEY)).toBeNull(); 504 }); 505 }); 506 507 describe("updateStep", () => { 508 it("updates step in stored state", () => { 509 const state = createInboundState({ step: "welcome" }); 510 saveMigrationState(state); 511 512 updateStep("migrating"); 513 514 const loaded = loadMigrationState(); 515 expect(loaded!.step).toBe("migrating"); 516 }); 517 518 it("does nothing when no state exists", () => { 519 expect(() => updateStep("migrating")).not.toThrow(); 520 expect(localStorage.getItem(STORAGE_KEY)).toBeNull(); 521 }); 522 }); 523 524 describe("setError", () => { 525 it("sets error and errorStep in stored state", () => { 526 const state = createInboundState({ step: "migrating" }); 527 saveMigrationState(state); 528 529 setError("Connection timeout", "migrating"); 530 531 const loaded = loadMigrationState(); 532 expect(loaded!.lastError).toBe("Connection timeout"); 533 expect(loaded!.lastErrorStep).toBe("migrating"); 534 }); 535 536 it("does nothing when no state exists", () => { 537 expect(() => setError("Error message", "welcome")).not.toThrow(); 538 expect(localStorage.getItem(STORAGE_KEY)).toBeNull(); 539 }); 540 }); 541});