Skip to content
Source
Sign inSign up
Open History to see the last commit

mediaActions.test.ts

213 lines7.3 KB

1import {
2 AuthFilesystemReadScope,
3 EnvironmentId,
4 ThreadId,
5 type AuthSessionState,
6} from "@t3tools/contracts";
7import { AsyncResult } from "effect/reactivity";
8import { beforeEach, expect, it, vi } from "vite-plus/test";
9​
10const state = vi.hoisted(() => ({
11 sessions: new Map<string, Pick<AuthSessionState, "authenticated" | "scopes">>(),
12 refresh: vi.fn(),
13 download: vi.fn(),
14 downloadPreview: vi.fn(),
15 saveImage: vi.fn(),
16 shareLocal: vi.fn(),
17 shareDraft: vi.fn(),
18 navigate: vi.fn(),
19 copy: vi.fn(),
20}));
21​
22vi.mock("react", () => ({
23 useEffect: () => {},
24 useRef: <A>(current: A) => ({ current }),
25 useState: <A>(initial: A) => [initial, () => {}],
26}));
27vi.mock("react-native", () => ({ Alert: { alert: vi.fn() } }));
28vi.mock("@react-navigation/native", () => ({
29 useNavigation: () => ({ navigate: state.navigate }),
30}));
31vi.mock("./markdownLinks", () => ({
32 normalizeNativeMarkdownUrl: (uri: string) => uri,
33}));
34vi.mock("../state/assets", () => ({
35 useRefreshAssetUrl: (environmentId: string, resource: unknown) => () =>
36 state.refresh(environmentId, resource),
37}));
38vi.mock("../state/session", () => ({
39 environmentSession: { sessionStateAtom: (environmentId: string) => environmentId },
40}));
41vi.mock("../state/query", () => ({
42 useEnvironmentQuery: (environmentId: string) => ({
43 data: state.sessions.get(environmentId) ?? null,
44 error: null,
45 }),
46}));
47vi.mock("../state/atom-registry", () => ({
48 appAtomRegistry: {
49 get: (environmentId: string) => {
50 const session = state.sessions.get(environmentId);
51 return session === undefined ? AsyncResult.initial() : AsyncResult.success(session);
52 },
53 },
54}));
55vi.mock("./attachmentDownload", () => ({
56 downloadAndShareAttachment: state.download,
57 downloadAttachmentForPreview: state.downloadPreview,
58 shareLocalAttachment: state.shareLocal,
59}));
60vi.mock("./saveImageToLibrary", () => ({
61 canSaveImageToLibrary: (mimeType: string) => mimeType === "image/png",
62 saveImageToLibrary: state.saveImage,
63}));
64vi.mock("expo-haptics", () => ({
65 notificationAsync: async () => {},
66 NotificationFeedbackType: { Success: "success" },
67}));
68vi.mock("./copyTextWithHaptic", () => ({ copyTextWithHaptic: state.copy }));
69vi.mock("./localAttachmentPreview", () => ({
70 loadLocalAttachmentPreview: async () => ({ share: state.shareDraft, dispose: vi.fn() }),
71}));
72​
73import { useMediaActions } from "../state/mediaActions";
74import type { MediaActionsSource } from "./mediaActionsSource";
75​
76const environmentId = EnvironmentId.make("media-environment");
77const otherEnvironmentId = EnvironmentId.make("other-environment");
78const threadId = ThreadId.make("media-thread");
79const granted: Pick<AuthSessionState, "authenticated" | "scopes"> = {
80 authenticated: true,
81 scopes: [AuthFilesystemReadScope],
82};
83const denied: Pick<AuthSessionState, "authenticated" | "scopes"> = {
84 authenticated: true,
85 scopes: [],
86};
87​
88function hostSource(_tag: "workspace-file" | "media-file" = "media-file"): MediaActionsSource {
89 return {
90 environmentId,
91 threadId,
92 resource: { _tag, threadId, path: "/repo/image.png" },
93 reference: { kind: "file", path: "/repo/image.png", relativePath: "image.png" },
94 name: "image.png",
95 mimeType: "image/png",
96 };
98​
99beforeEach(() => {
100 state.sessions.clear();
101 state.sessions.set(environmentId, denied);
102 state.refresh.mockReset().mockResolvedValue("https://host.test/image.png");
103 state.download.mockReset().mockResolvedValue(undefined);
104 state.downloadPreview.mockReset();
105 state.saveImage.mockReset().mockResolvedValue(undefined);
106 state.shareLocal.mockReset().mockResolvedValue(undefined);
107 state.shareDraft.mockReset().mockResolvedValue(undefined);
108 state.navigate.mockReset();
109 state.copy.mockReset();
110});
111​
112it.each(["workspace-file", "media-file"] as const)(
113 "blocks denied %s sharing and file opening while preserving path copying",
114 async (_tag) => {
115 const media = useMediaActions(hostSource(_tag));
116 await media.share();
117 media.actions.find(({ id }) => id === "open-file")!.run();
118 media.actions.find(({ id }) => id === "copy-full-path")!.run();
119​
120 expect(state.refresh).not.toHaveBeenCalled();
121 expect(state.download).not.toHaveBeenCalled();
122 expect(state.navigate).not.toHaveBeenCalled();
123 expect(state.copy).toHaveBeenCalledWith("/repo/image.png");
124 expect(media.actions.find(({ id }) => id === "save")?.disabled).toBe(true);
125 expect(media.actions.find(({ id }) => id === "save-image")?.disabled).toBe(true);
126 expect(media.actions.find(({ id }) => id === "open-file")?.disabled).toBe(true);
127 },
128);
129​
130it("rechecks a retained menu action after revocation", async () => {
131 state.sessions.set(environmentId, granted);
132 const media = useMediaActions(hostSource());
133 state.sessions.set(environmentId, denied);
134​
135 await media.share();
136 media.actions.find(({ id }) => id === "open-file")!.run();
137​
138 expect(state.refresh).not.toHaveBeenCalled();
139 expect(state.navigate).not.toHaveBeenCalled();
140});
141​
142it("reenables sharing after gaining access while preserving a retained callback", async () => {
143 const media = useMediaActions(hostSource());
144 state.sessions.set(environmentId, granted);
145​
146 await media.share();
147​
148 expect(state.download).toHaveBeenCalledOnce();
149 expect(useMediaActions(hostSource()).actions.find(({ id }) => id === "save")?.disabled).toBe(
150 false,
151 );
152});
153​
154it.each([false, true])("uses the media environment's grant (allowed: %s)", async (allowed) => {
155 state.sessions.set(environmentId, allowed ? granted : denied);
156 state.sessions.set(otherEnvironmentId, allowed ? denied : granted);
157​
158 await useMediaActions(hostSource()).share();
159​
160 expect(state.refresh).toHaveBeenCalledTimes(allowed ? 1 : 0);
161 expect(state.download).toHaveBeenCalledTimes(allowed ? 1 : 0);
162});
163​
164it("stops before downloading if access is revoked while the URL is refreshed", async () => {
165 state.sessions.set(environmentId, granted);
166 state.refresh.mockImplementation(async () => {
167 state.sessions.set(environmentId, denied);
168 return "https://host.test/image.png";
169 });
170​
171 await useMediaActions(hostSource()).share();
172​
173 expect(state.refresh).toHaveBeenCalledOnce();
174 expect(state.download).not.toHaveBeenCalled();
175});
176​
177it("lets an unresolved grant be authorized by an explicit server request", async () => {
178 state.sessions.delete(environmentId);
179​
180 await useMediaActions(hostSource()).share();
181​
182 expect(state.refresh).toHaveBeenCalledOnce();
183 expect(state.download).toHaveBeenCalledOnce();
184});
185​
186it.each(["https://cdn.test/image.png", "file:///device/image.png"])(
187 "shares direct media without a host grant: %s",
188 async (uri) => {
189 await useMediaActions({ uri, name: "image.png", mimeType: "image/png" }).share();
190​
191 expect(state.refresh).not.toHaveBeenCalled();
192 expect(uri.startsWith("file:") ? state.shareLocal : state.download).toHaveBeenCalledOnce();
193 },
194);
195​
196it("saves a host image from its downloaded file, then removes the file", async () => {
197 state.sessions.set(environmentId, granted);
198 const disposed = Promise.withResolvers<void>();
199 state.downloadPreview.mockResolvedValue({
200 uri: "file:///cache/image.png",
201 dispose: disposed.resolve,
202 });
203​
204 useMediaActions(hostSource())
205 .actions.find(({ id }) => id === "save-image")!
206 .run();
207 await disposed.promise;
208​
209 expect(state.downloadPreview).toHaveBeenCalledWith(
210 expect.objectContaining({ url: "https://host.test/image.png" }),
211 );
212 expect(state.saveImage).toHaveBeenCalledWith("file:///cache/image.png", "image/png", "image.png");
213});