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