1import { describe, expect, it, vi } from "@effect/vitest";
2import type { SharePayload } from "expo-sharing";
3
4import type { IncomingShareDraft } from "./incoming-share-model";
5import { IncomingShareInbox, type IncomingShareInboxDependencies } from "./incoming-share-inbox";
6
7const PAYLOAD: SharePayload = {
8 shareType: "text",
9 mimeType: "text/plain",
10 value: "Fix this",
11};
12
13function draft(id: string, createdAt = "2026-07-16T08:00:00.000Z"): IncomingShareDraft {
14 return {
15 schemaVersion: 1,
16 id,
17 createdAt,
18 text: PAYLOAD.value,
19 attachments: [],
20 warnings: [],
21 };
22}
23
24function deferred() {
25 let resolve!: () => void;
26 const promise = new Promise<void>((next) => {
27 resolve = next;
28 });
29 return { promise, resolve };
30}
31
32function createHarness(overrides: Partial<IncomingShareInboxDependencies> = {}) {
33 const persisted = new Map<string, IncomingShareDraft>();
34 let payloads: ReadonlyArray<SharePayload> = [PAYLOAD];
35 const dependencies: IncomingShareInboxDependencies = {
36 loadDrafts: async () => [...persisted.values()],
37 writeDraft: async (value) => {
38 persisted.set(value.id, value);
39 },
40 removeDraft: async (shareId) => {
41 persisted.delete(shareId);
42 },
43 getPayloads: () => payloads,
44 clearPayloads: () => {
45 payloads = [];
46 },
47 buildDraft: async ({ id, createdAt }) => ({
48 draft: draft(id, createdAt),
49 cleanup: async () => undefined,
50 }),
51 idForPayloads: async () => "share-stable",
52 now: () => "2026-07-16T08:00:00.000Z",
53 ...overrides,
54 };
55 return { inbox: new IncomingShareInbox(dependencies), persisted };
56}
57
58describe("IncomingShareInbox", () => {
59 it("coalesces a replay of an already-persisted native handoff", async () => {
60 const buildDraft = vi.fn(async ({ id, createdAt }) => ({
61 draft: draft(id, createdAt),
62 cleanup: async () => undefined,
63 }));
64 const cleanupReplayedPayloads = vi.fn(async () => undefined);
65 const { inbox, persisted } = createHarness({ buildDraft, cleanupReplayedPayloads });
66 persisted.set("share-stable", draft("share-stable"));
67
68 await expect(inbox.refresh({ ingestNative: true })).resolves.toEqual([draft("share-stable")]);
69 expect(buildDraft).not.toHaveBeenCalled();
70 expect(cleanupReplayedPayloads).toHaveBeenCalledWith([PAYLOAD]);
71 });
72
73 it("serializes concurrent refreshes so one native payload creates one inbox item", async () => {
74 const building = deferred();
75 const buildDraft = vi.fn(async ({ id, createdAt }) => {
76 await building.promise;
77 return { draft: draft(id, createdAt), cleanup: async () => undefined };
78 });
79 const { inbox } = createHarness({ buildDraft });
80
81 const first = inbox.refresh({ ingestNative: true });
82 const second = inbox.refresh({ ingestNative: true });
83 building.resolve();
84
85 await expect(Promise.all([first, second])).resolves.toEqual([
86 [draft("share-stable")],
87 [draft("share-stable")],
88 ]);
89 expect(buildDraft).toHaveBeenCalledTimes(1);
90 });
91
92 it("orders consumption after an in-flight refresh without restoring stale state", async () => {
93 const building = deferred();
94 const { inbox, persisted } = createHarness({
95 buildDraft: async ({ id, createdAt }) => {
96 await building.promise;
97 return { draft: draft(id, createdAt), cleanup: async () => undefined };
98 },
99 });
100
101 const refresh = inbox.refresh({ ingestNative: true });
102 const consume = inbox.consume("share-stable");
103 building.resolve();
104
105 await expect(refresh).resolves.toEqual([draft("share-stable")]);
106 await expect(consume).resolves.toEqual([]);
107 expect([...persisted.values()]).toEqual([]);
108 });
109
110 it("consumes only the addressed share when another share has identical content", async () => {
111 const { inbox, persisted } = createHarness();
112 persisted.set("share-first", draft("share-first", "2026-07-16T07:59:00.000Z"));
113 persisted.set("share-second", draft("share-second"));
114
115 await expect(inbox.refresh({ ingestNative: false })).resolves.toEqual([
116 draft("share-second"),
117 draft("share-first", "2026-07-16T07:59:00.000Z"),
118 ]);
119 await expect(inbox.consume("share-second")).resolves.toEqual([
120 draft("share-first", "2026-07-16T07:59:00.000Z"),
121 ]);
122 expect([...persisted.values()]).toEqual([draft("share-first", "2026-07-16T07:59:00.000Z")]);
123 });
124
125 it("does not acknowledge a supported payload when its durable write fails", async () => {
126 const clearPayloads = vi.fn();
127 const cleanup = vi.fn(async () => undefined);
128 const rollback = vi.fn(async () => undefined);
129 const { inbox } = createHarness({
130 clearPayloads,
131 buildDraft: async ({ id, createdAt }) => ({
132 draft: draft(id, createdAt),
133 cleanup,
134 rollback,
135 }),
136 writeDraft: async () => {
137 throw new Error("disk full");
138 },
139 });
140
141 await expect(inbox.refresh({ ingestNative: true })).rejects.toThrow("disk full");
142 expect(clearPayloads).not.toHaveBeenCalled();
143 expect(cleanup).not.toHaveBeenCalled();
144 expect(rollback).toHaveBeenCalledOnce();
145 });
146
147 it("durably reserves a share for one project before draft import", async () => {
148 const { inbox, persisted } = createHarness();
149 persisted.set("share-stable", draft("share-stable"));
150 const destination = { environmentId: "environment-1", projectId: "project-1" };
151
152 await expect(inbox.reserve("share-stable", destination)).resolves.toEqual([
153 { ...draft("share-stable"), destination },
154 ]);
155 expect(persisted.get("share-stable")?.destination).toEqual(destination);
156 await expect(inbox.reserve("share-stable", destination)).resolves.toEqual([
157 { ...draft("share-stable"), destination },
158 ]);
159 });
160
161 it("rejects moving a reserved share to another project", async () => {
162 const { inbox, persisted } = createHarness();
163 persisted.set("share-stable", {
164 ...draft("share-stable"),
165 destination: { environmentId: "environment-1", projectId: "project-1" },
166 });
167
168 await expect(
169 inbox.reserve("share-stable", {
170 environmentId: "environment-1",
171 projectId: "project-2",
172 }),
173 ).rejects.toThrow("already reserved");
174 expect(persisted.get("share-stable")?.destination?.projectId).toBe("project-1");
175 });
176
177 it("conditionally releases a reservation when its project is unavailable", async () => {
178 const { inbox, persisted } = createHarness();
179 const destination = { environmentId: "environment-1", projectId: "project-1" };
180 persisted.set("share-stable", { ...draft("share-stable"), destination });
181
182 await expect(inbox.releaseReservation("share-stable", destination)).resolves.toEqual([
183 draft("share-stable"),
184 ]);
185 expect(persisted.get("share-stable")?.destination).toBeUndefined();
186
187 await expect(
188 inbox.reserve("share-stable", {
189 environmentId: "environment-2",
190 projectId: "project-2",
191 }),
192 ).resolves.toEqual([
193 {
194 ...draft("share-stable"),
195 destination: { environmentId: "environment-2", projectId: "project-2" },
196 },
197 ]);
198 });
199
200 it("does not release a reservation that changed concurrently", async () => {
201 const { inbox, persisted } = createHarness();
202 persisted.set("share-stable", {
203 ...draft("share-stable"),
204 destination: { environmentId: "environment-2", projectId: "project-2" },
205 });
206
207 await expect(
208 inbox.releaseReservation("share-stable", {
209 environmentId: "environment-1",
210 projectId: "project-1",
211 }),
212 ).rejects.toThrow("reservation changed");
213 expect(persisted.get("share-stable")?.destination?.projectId).toBe("project-2");
214 });
215
216 it("treats an already-removed share as an already-released reservation", async () => {
217 const { inbox, persisted } = createHarness();
218 persisted.set("share-other", draft("share-other"));
219
220 await expect(
221 inbox.releaseReservation("share-stable", {
222 environmentId: "environment-1",
223 projectId: "project-1",
224 }),
225 ).resolves.toEqual([draft("share-other")]);
226 expect([...persisted.values()]).toEqual([draft("share-other")]);
227 });
228});