| 1 | import { describe, expect, it, vi } from "@effect/vitest"; |
| 2 | import type { SharePayload } from "expo-sharing"; |
| 3 | |
| 4 | import type { IncomingShareDraft } from "./incoming-share-model"; |
| 5 | import { IncomingShareInbox, type IncomingShareInboxDependencies } from "./incoming-share-inbox"; |
| 6 | |
| 7 | const PAYLOAD: SharePayload = { |
| 8 | shareType: "text", |
| 9 | mimeType: "text/plain", |
| 10 | value: "Fix this", |
| 11 | }; |
| 12 | |
| 13 | function 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 | |
| 24 | function deferred() { |
| 25 | let resolve!: () => void; |
| 26 | const promise = new Promise<void>((next) => { |
| 27 | resolve = next; |
| 28 | }); |
| 29 | return { promise, resolve }; |
| 30 | } |
| 31 | |
| 32 | function 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 | |
| 58 | describe("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("keeps content-identical shares addressable by their own ids", async () => { |
| 126 | const { inbox, persisted } = createHarness(); |
| 127 | persisted.set("share-open-flow", draft("share-open-flow", "2026-07-16T07:59:00.000Z")); |
| 128 | persisted.set("share-newer", draft("share-newer")); |
| 129 | |
| 130 | await expect(inbox.refresh({ ingestNative: false })).resolves.toEqual([ |
| 131 | draft("share-newer"), |
| 132 | draft("share-open-flow", "2026-07-16T07:59:00.000Z"), |
| 133 | ]); |
| 134 | }); |
| 135 | |
| 136 | it("does not acknowledge a supported payload when its durable write fails", async () => { |
| 137 | const clearPayloads = vi.fn(); |
| 138 | const cleanup = vi.fn(async () => undefined); |
| 139 | const rollback = vi.fn(async () => undefined); |
| 140 | const { inbox } = createHarness({ |
| 141 | clearPayloads, |
| 142 | buildDraft: async ({ id, createdAt }) => ({ |
| 143 | draft: draft(id, createdAt), |
| 144 | cleanup, |
| 145 | rollback, |
| 146 | }), |
| 147 | writeDraft: async () => { |
| 148 | throw new Error("disk full"); |
| 149 | }, |
| 150 | }); |
| 151 | |
| 152 | await expect(inbox.refresh({ ingestNative: true })).rejects.toThrow("disk full"); |
| 153 | expect(clearPayloads).not.toHaveBeenCalled(); |
| 154 | expect(cleanup).not.toHaveBeenCalled(); |
| 155 | expect(rollback).toHaveBeenCalledOnce(); |
| 156 | }); |
| 157 | |
| 158 | it("durably reserves a share for one project before draft import", async () => { |
| 159 | const { inbox, persisted } = createHarness(); |
| 160 | persisted.set("share-stable", draft("share-stable")); |
| 161 | const destination = { environmentId: "environment-1", projectId: "project-1" }; |
| 162 | |
| 163 | await expect(inbox.reserve("share-stable", destination)).resolves.toEqual([ |
| 164 | { ...draft("share-stable"), destination }, |
| 165 | ]); |
| 166 | expect(persisted.get("share-stable")?.destination).toEqual(destination); |
| 167 | await expect(inbox.reserve("share-stable", destination)).resolves.toEqual([ |
| 168 | { ...draft("share-stable"), destination }, |
| 169 | ]); |
| 170 | }); |
| 171 | |
| 172 | it("rejects moving a reserved share to another project", async () => { |
| 173 | const { inbox, persisted } = createHarness(); |
| 174 | persisted.set("share-stable", { |
| 175 | ...draft("share-stable"), |
| 176 | destination: { environmentId: "environment-1", projectId: "project-1" }, |
| 177 | }); |
| 178 | |
| 179 | await expect( |
| 180 | inbox.reserve("share-stable", { |
| 181 | environmentId: "environment-1", |
| 182 | projectId: "project-2", |
| 183 | }), |
| 184 | ).rejects.toThrow("already reserved"); |
| 185 | expect(persisted.get("share-stable")?.destination?.projectId).toBe("project-1"); |
| 186 | }); |
| 187 | |
| 188 | it("conditionally releases a reservation when its project is unavailable", async () => { |
| 189 | const { inbox, persisted } = createHarness(); |
| 190 | const destination = { environmentId: "environment-1", projectId: "project-1" }; |
| 191 | persisted.set("share-stable", { ...draft("share-stable"), destination }); |
| 192 | |
| 193 | await expect(inbox.releaseReservation("share-stable", destination)).resolves.toEqual([ |
| 194 | draft("share-stable"), |
| 195 | ]); |
| 196 | expect(persisted.get("share-stable")?.destination).toBeUndefined(); |
| 197 | |
| 198 | await expect( |
| 199 | inbox.reserve("share-stable", { |
| 200 | environmentId: "environment-2", |
| 201 | projectId: "project-2", |
| 202 | }), |
| 203 | ).resolves.toEqual([ |
| 204 | { |
| 205 | ...draft("share-stable"), |
| 206 | destination: { environmentId: "environment-2", projectId: "project-2" }, |
| 207 | }, |
| 208 | ]); |
| 209 | }); |
| 210 | |
| 211 | it("does not release a reservation that changed concurrently", async () => { |
| 212 | const { inbox, persisted } = createHarness(); |
| 213 | persisted.set("share-stable", { |
| 214 | ...draft("share-stable"), |
| 215 | destination: { environmentId: "environment-2", projectId: "project-2" }, |
| 216 | }); |
| 217 | |
| 218 | await expect( |
| 219 | inbox.releaseReservation("share-stable", { |
| 220 | environmentId: "environment-1", |
| 221 | projectId: "project-1", |
| 222 | }), |
| 223 | ).rejects.toThrow("reservation changed"); |
| 224 | expect(persisted.get("share-stable")?.destination?.projectId).toBe("project-2"); |
| 225 | }); |
| 226 | |
| 227 | it("treats an already-removed share as an already-released reservation", async () => { |
| 228 | const { inbox, persisted } = createHarness(); |
| 229 | persisted.set("share-other", draft("share-other")); |
| 230 | |
| 231 | await expect( |
| 232 | inbox.releaseReservation("share-stable", { |
| 233 | environmentId: "environment-1", |
| 234 | projectId: "project-1", |
| 235 | }), |
| 236 | ).resolves.toEqual([draft("share-other")]); |
| 237 | expect([...persisted.values()]).toEqual([draft("share-other")]); |
| 238 | }); |
| 239 | }); |