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