Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
212 changes: 202 additions & 10 deletions apps/mobile/src/features/sharing/incoming-share-storage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,34 +2,116 @@ 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<string, string>();
let failNextWrite = false;
let failRelocationTo = "";
let snapshots: Map<string, string>[] | 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<void> {
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<string> {
return this.contents;
return files.get(this.name)!;
}
}

class Directory {
create(): void {}

list(): ReadonlyArray<File> {
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;
},
};
});
Expand All @@ -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,
Expand All @@ -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);
});
});
51 changes: 41 additions & 10 deletions apps/mobile/src/features/sharing/incoming-share-storage.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -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?: {
Expand All @@ -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) {
Expand All @@ -65,20 +83,33 @@ export async function loadIncomingShareDrafts(options?: {
export async function writeIncomingShareDraft(draft: IncomingShareDraft): Promise<void> {
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 });
}
}

export async function removeIncomingShareDraft(shareId: string): Promise<void> {
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 });
Expand Down
Loading