diff --git a/apps/mobile/src/features/sharing/incoming-share-storage.test.ts b/apps/mobile/src/features/sharing/incoming-share-storage.test.ts index 82913d48fd2e..ceaaff3216a2 100644 --- a/apps/mobile/src/features/sharing/incoming-share-storage.test.ts +++ b/apps/mobile/src/features/sharing/incoming-share-storage.test.ts @@ -2,18 +2,84 @@ import { afterEach, describe, expect, it } from "@effect/vitest"; import { vi } from "vite-plus/test"; const fileSystemMocks = vi.hoisted(() => { - let entries: File[] = []; + const files = new Map(); + let failNextWrite = false; + let failRelocationTo = ""; + let snapshots: Map[] | undefined; + const record = () => snapshots?.push(new Map(files)); class File { - readonly exists = true; + name: string; + readonly parentDirectory: Directory; + readonly contents: string; - constructor( - readonly name: string, - private readonly contents: string, - ) {} + constructor(parent: string | Directory, nameOrContents: string) { + this.name = typeof parent === "string" ? parent : nameOrContents; + this.parentDirectory = typeof parent === "string" ? new Directory() : parent; + this.contents = typeof parent === "string" ? nameOrContents : ""; + } + + get exists(): boolean { + return files.has(this.name); + } + + create(): void { + files.set(this.name, ""); + record(); + } + + async write(contents: string): Promise { + files.set(this.name, contents.slice(0, 1)); + record(); + if (failNextWrite) { + failNextWrite = false; + throw new Error("interrupted write"); + } + files.set(this.name, contents); + record(); + } + + copySync(destination: File): void { + files.delete(destination.name); + record(); + files.set(destination.name, files.get(this.name)!.slice(0, 1)); + record(); + if (destination.name.endsWith(failRelocationTo) && failRelocationTo) { + failRelocationTo = ""; + throw new Error("copy failed after partial destination write"); + } + files.set(destination.name, files.get(this.name)!); + record(); + } + + moveSync(destination: File): void { + // Expo removes the destination before attempting the native move. + files.delete(destination.name); + record(); + if (destination.name.endsWith(failRelocationTo) && failRelocationTo) { + failRelocationTo = ""; + throw new Error("move failed after destination removal"); + } + // Android can fall back to copy-and-delete after native moves fail. + files.set(destination.name, files.get(this.name)!.slice(0, 1)); + record(); + if (failRelocationTo === "partial") { + failRelocationTo = ""; + throw new Error("move failed during fallback copy"); + } + files.set(destination.name, files.get(this.name)!); + files.delete(this.name); + this.name = destination.name; + record(); + } + + delete(): void { + files.delete(this.name); + record(); + } async text(): Promise { - return this.contents; + return files.get(this.name)!; } } @@ -21,15 +87,31 @@ const fileSystemMocks = vi.hoisted(() => { create(): void {} list(): ReadonlyArray { - return entries; + return [...files].map(([name, contents]) => new File(name, contents)); } } return { Directory, File, + failNextWrite() { + failNextWrite = true; + }, + failRelocationTo(suffix: string) { + failRelocationTo = suffix; + }, + trackOperations() { + snapshots = []; + return snapshots; + }, setEntries(next: File[]) { - entries = next; + files.clear(); + for (const file of next) { + files.set(file.name, file.contents); + } + failNextWrite = false; + failRelocationTo = ""; + snapshots = undefined; }, }; }); @@ -40,7 +122,12 @@ vi.mock("expo-file-system", () => ({ Paths: { document: "/documents" }, })); -import { loadIncomingShareDrafts } from "./incoming-share-storage"; +import { + IncomingShareStorageError, + loadIncomingShareDrafts, + removeIncomingShareDraft, + writeIncomingShareDraft, +} from "./incoming-share-storage"; const VALID_DRAFT = { schemaVersion: 1, @@ -67,4 +154,109 @@ describe("incoming share storage", () => { await expect(loadIncomingShareDrafts()).resolves.toEqual([VALID_DRAFT]); expect(warning).toHaveBeenCalledOnce(); }); + + it.each(["write", "partial"])("retries an interrupted initial %s", async (failure) => { + if (failure === "write") fileSystemMocks.failNextWrite(); + else fileSystemMocks.failRelocationTo(failure); + + await expect(writeIncomingShareDraft(VALID_DRAFT)).rejects.toMatchObject({ + operation: "write", + shareId: VALID_DRAFT.id, + }); + vi.spyOn(console, "warn").mockImplementation(() => undefined); + await expect(loadIncomingShareDrafts()).resolves.toEqual([]); + + await writeIncomingShareDraft(VALID_DRAFT); + await expect(loadIncomingShareDrafts({ strict: true })).resolves.toEqual([VALID_DRAFT]); + }); + + it("preserves a saved share when its reservation write fails and allows retrying", async () => { + await writeIncomingShareDraft(VALID_DRAFT); + const reserved = { + ...VALID_DRAFT, + destination: { environmentId: "env-1", projectId: "project-1" }, + }; + fileSystemMocks.failNextWrite(); + + await expect(writeIncomingShareDraft(reserved)).rejects.toBeInstanceOf( + IncomingShareStorageError, + ); + await expect(loadIncomingShareDrafts({ strict: true })).resolves.toEqual([VALID_DRAFT]); + + await writeIncomingShareDraft(reserved); + await expect(loadIncomingShareDrafts({ strict: true })).resolves.toEqual([reserved]); + }); + + it("removes staging and backup files after a successful replacement", async () => { + await writeIncomingShareDraft(VALID_DRAFT); + const updated = { ...VALID_DRAFT, text: "Updated" }; + + await writeIncomingShareDraft(updated); + + expect(new fileSystemMocks.Directory().list().map((file) => file.name)).toEqual([ + `${VALID_DRAFT.id}.json`, + ]); + await expect(loadIncomingShareDrafts({ strict: true })).resolves.toEqual([updated]); + }); + + it("keeps a successful replacement when backup cleanup fails", async () => { + await writeIncomingShareDraft(VALID_DRAFT); + const updated = { ...VALID_DRAFT, text: "Updated" }; + vi.spyOn(fileSystemMocks.File.prototype, "delete").mockImplementationOnce(() => { + throw new Error("backup cleanup failed"); + }); + + await expect(writeIncomingShareDraft(updated)).resolves.toBeUndefined(); + + expect(new fileSystemMocks.File(`${VALID_DRAFT.id}.json.bak`, "").exists).toBe(true); + expect(new fileSystemMocks.File(`${VALID_DRAFT.id}.json.tmp`, "").exists).toBe(false); + await expect(loadIncomingShareDrafts({ strict: true })).resolves.toEqual([updated]); + }); + + it.each([".bak", ".json", "partial"])( + "recovers after a failed replacement of %s", + async (suffix) => { + await writeIncomingShareDraft(VALID_DRAFT); + fileSystemMocks.failRelocationTo(suffix); + + await expect( + writeIncomingShareDraft({ ...VALID_DRAFT, text: "Updated" }), + ).rejects.toBeInstanceOf(IncomingShareStorageError); + await expect(loadIncomingShareDrafts({ strict: true })).resolves.toEqual([VALID_DRAFT]); + + // A second failed write must not damage a backup left by the failed move. + fileSystemMocks.failNextWrite(); + await expect(writeIncomingShareDraft(VALID_DRAFT)).rejects.toBeInstanceOf( + IncomingShareStorageError, + ); + await expect(loadIncomingShareDrafts({ strict: true })).resolves.toEqual([VALID_DRAFT]); + fileSystemMocks.failRelocationTo(".json"); + await expect(writeIncomingShareDraft(VALID_DRAFT)).rejects.toBeInstanceOf( + IncomingShareStorageError, + ); + await expect(loadIncomingShareDrafts({ strict: true })).resolves.toEqual([VALID_DRAFT]); + await writeIncomingShareDraft(VALID_DRAFT); + await removeIncomingShareDraft(VALID_DRAFT.id); + await expect(loadIncomingShareDrafts({ strict: true })).resolves.toEqual([]); + }, + ); + + it("loads one complete share after interruption at each replacement operation", async () => { + await writeIncomingShareDraft(VALID_DRAFT); + const updated = { ...VALID_DRAFT, text: "Updated" }; + const snapshots = fileSystemMocks.trackOperations(); + await writeIncomingShareDraft(updated); + + for (const snapshot of snapshots) { + fileSystemMocks.setEntries( + [...snapshot].map(([name, contents]) => new fileSystemMocks.File(name, contents)), + ); + const loaded = await loadIncomingShareDrafts({ strict: true }); + expect(loaded).toHaveLength(1); + expect([VALID_DRAFT, updated]).toContainEqual(loaded[0]); + await removeIncomingShareDraft(VALID_DRAFT.id); + await expect(loadIncomingShareDrafts({ strict: true })).resolves.toEqual([]); + } + expect(snapshots.length).toBeGreaterThan(0); + }); }); diff --git a/apps/mobile/src/features/sharing/incoming-share-storage.ts b/apps/mobile/src/features/sharing/incoming-share-storage.ts index 763da41cba8c..06d64e4bc349 100644 --- a/apps/mobile/src/features/sharing/incoming-share-storage.ts +++ b/apps/mobile/src/features/sharing/incoming-share-storage.ts @@ -1,4 +1,5 @@ import * as Schema from "effect/Schema"; +import type { File } from "expo-file-system"; import { decodeIncomingShareDraft, type IncomingShareDraft } from "./incoming-share-model"; @@ -28,9 +29,23 @@ async function getDirectory() { return directory; } -async function getFile(shareId: string) { +async function getFile(shareId: string, suffix = "") { const { File } = await import("expo-file-system"); - return new File(await getDirectory(), fileName(shareId)); + return new File(await getDirectory(), `${fileName(shareId)}${suffix}`); +} + +async function readSavedDraft(file: File) { + try { + return { file, draft: decodeIncomingShareDraft(JSON.parse(await file.text()) as unknown) }; + } catch (cause) { + const { File } = await import("expo-file-system"); + const backup = new File(file.parentDirectory, `${file.name}.bak`); + if (!backup.exists) throw cause; + return { + file: backup, + draft: decodeIncomingShareDraft(JSON.parse(await backup.text()) as unknown), + }; + } } export async function loadIncomingShareDrafts(options?: { @@ -40,11 +55,14 @@ export async function loadIncomingShareDrafts(options?: { const { File } = await import("expo-file-system"); const drafts: IncomingShareDraft[] = []; for (const entry of (await getDirectory()).list()) { - if (!(entry instanceof File) || !entry.name.endsWith(".json")) { + if (!(entry instanceof File)) continue; + if (entry.name.endsWith(".json.bak")) { + if (new File(entry.parentDirectory, entry.name.slice(0, -4)).exists) continue; + } else if (!entry.name.endsWith(".json")) { continue; } try { - drafts.push(decodeIncomingShareDraft(JSON.parse(await entry.text()) as unknown)); + drafts.push((await readSavedDraft(entry)).draft); } catch (cause) { const error = new IncomingShareStorageError({ operation: "load", shareId: null, cause }); if (options?.strict) { @@ -65,10 +83,22 @@ export async function loadIncomingShareDrafts(options?: { export async function writeIncomingShareDraft(draft: IncomingShareDraft): Promise { try { const file = await getFile(draft.id); - if (!file.exists) { - file.create({ intermediates: true, overwrite: true }); + const backup = await getFile(draft.id, ".bak"); + const temp = await getFile(draft.id, ".tmp"); + // Inbox mutations are serialized, so each share can reuse its staging path. + temp.create({ intermediates: true, overwrite: true }); + await temp.write(JSON.stringify(draft)); + // Expo can remove or partially copy the destination before a move fails. + if (file.exists) { + const saved = await readSavedDraft(file).catch(() => undefined); + if (!saved || saved.file === file) file.copySync(backup, { overwrite: true }); + } + temp.moveSync(file, { overwrite: true }); + try { + if (backup.exists) backup.delete(); + } catch { + // The installed JSON is authoritative even if backup cleanup fails. } - await file.write(JSON.stringify(draft)); } catch (cause) { throw new IncomingShareStorageError({ operation: "write", shareId: draft.id, cause }); } @@ -76,9 +106,10 @@ export async function writeIncomingShareDraft(draft: IncomingShareDraft): Promis export async function removeIncomingShareDraft(shareId: string): Promise { try { - const file = await getFile(shareId); - if (file.exists) { - file.delete(); + // Delete the backup first so a failed removal cannot resurrect a consumed share. + for (const suffix of [".tmp", ".bak", ""]) { + const file = await getFile(shareId, suffix); + if (file.exists) file.delete(); } } catch (cause) { throw new IncomingShareStorageError({ operation: "remove", shareId, cause });