forked from
tranquil.farm/tranquil-pds
Our Personal Data Server from scratch!
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});