Skip to content
Source
Sign inSign up

incoming-share-storage.test.ts

262 lines8.7 KB

1import { afterEach, describe, expect, it } from "@effect/vitest";
2import { vi } from "vite-plus/test";
3​
4const fileSystemMocks = vi.hoisted(() => {
5 const files = new Map<string, string>();
6 let failNextWrite = false;
7 let failRelocationTo = "";
8 let snapshots: Map<string, string>[] | undefined;
9 const record = () => snapshots?.push(new Map(files));
10​
11 class File {
12 name: string;
13 readonly parentDirectory: Directory;
14 readonly contents: string;
15​
16 constructor(parent: string | Directory, nameOrContents: string) {
17 this.name = typeof parent === "string" ? parent : nameOrContents;
18 this.parentDirectory = typeof parent === "string" ? new Directory() : parent;
19 this.contents = typeof parent === "string" ? nameOrContents : "";
20 }
21​
22 get exists(): boolean {
23 return files.has(this.name);
24 }
25​
26 create(): void {
27 files.set(this.name, "");
28 record();
29 }
30​
31 async write(contents: string): Promise<void> {
32 files.set(this.name, contents.slice(0, 1));
33 record();
34 if (failNextWrite) {
35 failNextWrite = false;
36 throw new Error("interrupted write");
37 }
38 files.set(this.name, contents);
39 record();
40 }
41​
42 copySync(destination: File): void {
43 files.delete(destination.name);
44 record();
45 files.set(destination.name, files.get(this.name)!.slice(0, 1));
46 record();
47 if (destination.name.endsWith(failRelocationTo) && failRelocationTo) {
48 failRelocationTo = "";
49 throw new Error("copy failed after partial destination write");
50 }
51 files.set(destination.name, files.get(this.name)!);
52 record();
53 }
54​
55 moveSync(destination: File): void {
56 // Expo removes the destination before attempting the native move.
57 files.delete(destination.name);
58 record();
59 if (destination.name.endsWith(failRelocationTo) && failRelocationTo) {
60 failRelocationTo = "";
61 throw new Error("move failed after destination removal");
62 }
63 // Android can fall back to copy-and-delete after native moves fail.
64 files.set(destination.name, files.get(this.name)!.slice(0, 1));
65 record();
66 if (failRelocationTo === "partial") {
67 failRelocationTo = "";
68 throw new Error("move failed during fallback copy");
69 }
70 files.set(destination.name, files.get(this.name)!);
71 files.delete(this.name);
72 this.name = destination.name;
73 record();
74 }
75​
76 delete(): void {
77 files.delete(this.name);
78 record();
79 }
80​
81 async text(): Promise<string> {
82 return files.get(this.name)!;
83 }
84 }
85​
86 class Directory {
87 create(): void {}
88​
89 list(): ReadonlyArray<File> {
90 return [...files].map(([name, contents]) => new File(name, contents));
91 }
92 }
93​
94 return {
95 Directory,
96 File,
97 failNextWrite() {
98 failNextWrite = true;
99 },
100 failRelocationTo(suffix: string) {
101 failRelocationTo = suffix;
102 },
103 trackOperations() {
104 snapshots = [];
105 return snapshots;
106 },
107 setEntries(next: File[]) {
108 files.clear();
109 for (const file of next) {
110 files.set(file.name, file.contents);
111 }
112 failNextWrite = false;
113 failRelocationTo = "";
114 snapshots = undefined;
115 },
116 };
117});
118​
119vi.mock("expo-file-system", () => ({
120 Directory: fileSystemMocks.Directory,
121 File: fileSystemMocks.File,
122 Paths: { document: "/documents" },
123}));
124​
125import {
126 IncomingShareStorageError,
127 loadIncomingShareDrafts,
128 removeIncomingShareDraft,
129 writeIncomingShareDraft,
130} from "./incoming-share-storage";
131​
132const VALID_DRAFT = {
133 schemaVersion: 1,
134 id: "share-valid",
135 createdAt: "2026-08-28T12:00:00.000Z",
136 text: "Review this file",
137 attachments: [],
138 warnings: [],
139} as const;
140​
141afterEach(() => {
142 fileSystemMocks.setEntries([]);
143 vi.restoreAllMocks();
144});
145​
146describe("incoming share storage", () => {
147 it("skips an invalid persisted share by default", async () => {
148 fileSystemMocks.setEntries([
149 new fileSystemMocks.File("valid.json", JSON.stringify(VALID_DRAFT)),
150 new fileSystemMocks.File("invalid.json", "{"),
151 ]);
152 const warning = vi.spyOn(console, "warn").mockImplementation(() => undefined);
153​
154 await expect(loadIncomingShareDrafts()).resolves.toEqual([VALID_DRAFT]);
155 expect(warning).toHaveBeenCalledOnce();
156 });
157​
158 it.each(["write", "partial"])("retries an interrupted initial %s", async (failure) => {
159 if (failure === "write") fileSystemMocks.failNextWrite();
160 else fileSystemMocks.failRelocationTo(failure);
161​
162 await expect(writeIncomingShareDraft(VALID_DRAFT)).rejects.toMatchObject({
163 operation: "write",
164 shareId: VALID_DRAFT.id,
165 });
166 vi.spyOn(console, "warn").mockImplementation(() => undefined);
167 await expect(loadIncomingShareDrafts()).resolves.toEqual([]);
168​
169 await writeIncomingShareDraft(VALID_DRAFT);
170 await expect(loadIncomingShareDrafts({ strict: true })).resolves.toEqual([VALID_DRAFT]);
171 });
172​
173 it("preserves a saved share when its reservation write fails and allows retrying", async () => {
174 await writeIncomingShareDraft(VALID_DRAFT);
175 const reserved = {
176 ...VALID_DRAFT,
177 destination: { environmentId: "env-1", projectId: "project-1" },
178 };
179 fileSystemMocks.failNextWrite();
180​
181 await expect(writeIncomingShareDraft(reserved)).rejects.toBeInstanceOf(
182 IncomingShareStorageError,
183 );
184 await expect(loadIncomingShareDrafts({ strict: true })).resolves.toEqual([VALID_DRAFT]);
185​
186 await writeIncomingShareDraft(reserved);
187 await expect(loadIncomingShareDrafts({ strict: true })).resolves.toEqual([reserved]);
188 });
189​
190 it("removes staging and backup files after a successful replacement", async () => {
191 await writeIncomingShareDraft(VALID_DRAFT);
192 const updated = { ...VALID_DRAFT, text: "Updated" };
193​
194 await writeIncomingShareDraft(updated);
195​
196 expect(new fileSystemMocks.Directory().list().map((file) => file.name)).toEqual([
197 `${VALID_DRAFT.id}.json`,
198 ]);
199 await expect(loadIncomingShareDrafts({ strict: true })).resolves.toEqual([updated]);
200 });
201​
202 it("keeps a successful replacement when backup cleanup fails", async () => {
203 await writeIncomingShareDraft(VALID_DRAFT);
204 const updated = { ...VALID_DRAFT, text: "Updated" };
205 vi.spyOn(fileSystemMocks.File.prototype, "delete").mockImplementationOnce(() => {
206 throw new Error("backup cleanup failed");
207 });
208​
209 await expect(writeIncomingShareDraft(updated)).resolves.toBeUndefined();
210​
211 expect(new fileSystemMocks.File(`${VALID_DRAFT.id}.json.bak`, "").exists).toBe(true);
212 expect(new fileSystemMocks.File(`${VALID_DRAFT.id}.json.tmp`, "").exists).toBe(false);
213 await expect(loadIncomingShareDrafts({ strict: true })).resolves.toEqual([updated]);
214 });
215​
216 it.each([".bak", ".json", "partial"])(
217 "recovers after a failed replacement of %s",
218 async (suffix) => {
219 await writeIncomingShareDraft(VALID_DRAFT);
220 fileSystemMocks.failRelocationTo(suffix);
221​
222 await expect(
223 writeIncomingShareDraft({ ...VALID_DRAFT, text: "Updated" }),
224 ).rejects.toBeInstanceOf(IncomingShareStorageError);
225 await expect(loadIncomingShareDrafts({ strict: true })).resolves.toEqual([VALID_DRAFT]);
226​
227 // A second failed write must not damage a backup left by the failed move.
228 fileSystemMocks.failNextWrite();
229 await expect(writeIncomingShareDraft(VALID_DRAFT)).rejects.toBeInstanceOf(
230 IncomingShareStorageError,
231 );
232 await expect(loadIncomingShareDrafts({ strict: true })).resolves.toEqual([VALID_DRAFT]);
233 fileSystemMocks.failRelocationTo(".json");
234 await expect(writeIncomingShareDraft(VALID_DRAFT)).rejects.toBeInstanceOf(
235 IncomingShareStorageError,
236 );
237 await expect(loadIncomingShareDrafts({ strict: true })).resolves.toEqual([VALID_DRAFT]);
238 await writeIncomingShareDraft(VALID_DRAFT);
239 await removeIncomingShareDraft(VALID_DRAFT.id);
240 await expect(loadIncomingShareDrafts({ strict: true })).resolves.toEqual([]);
241 },
242 );
243​
244 it("loads one complete share after interruption at each replacement operation", async () => {
245 await writeIncomingShareDraft(VALID_DRAFT);
246 const updated = { ...VALID_DRAFT, text: "Updated" };
247 const snapshots = fileSystemMocks.trackOperations();
248 await writeIncomingShareDraft(updated);
249​
250 for (const snapshot of snapshots) {
251 fileSystemMocks.setEntries(
252 [...snapshot].map(([name, contents]) => new fileSystemMocks.File(name, contents)),
253 );
254 const loaded = await loadIncomingShareDrafts({ strict: true });
255 expect(loaded).toHaveLength(1);
256 expect([VALID_DRAFT, updated]).toContainEqual(loaded[0]);
257 await removeIncomingShareDraft(VALID_DRAFT.id);
258 await expect(loadIncomingShareDrafts({ strict: true })).resolves.toEqual([]);
259 }
260 expect(snapshots.length).toBeGreaterThan(0);
261 });
262});