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