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});