1import {
2 AuthFilesystemReadScope,
3 EnvironmentAuthorizationError,
4 EnvironmentId,
5 ThreadId,
6 type AuthSessionState,
7} from "@t3tools/contracts";
8import * as Cause from "effect/Cause";
9import { AsyncResult } from "effect/reactivity";
10import { beforeEach, expect, it, vi } from "vite-plus/test";
11
12const state = vi.hoisted(() => ({
13 session: null as Pick<AuthSessionState, "authenticated" | "scopes"> | null,
14 phase: "connected" as "connected" | "offline",
15 assetAtom: {},
16 mint: vi.fn(),
17 assetQuery: vi.fn(),
18}));
19
20vi.mock("react", () => ({ useCallback: <A>(callback: A) => callback }));
21vi.mock("@effect/atom-react", () => ({
22 useAtomValue: (atom: unknown) =>
23 atom === state.assetAtom
24 ? AsyncResult.success({ relativeUrl: "/api/assets/image.png", expiresAt: 1 })
25 : AsyncResult.initial(false),
26}));
27vi.mock("~/state/session", () => ({
28 usePreparedConnection: () => ({ _tag: "Some", value: { httpBaseUrl: "https://host.test" } }),
29}));
30vi.mock("~/state/filesystem", async () => {
31 const { resolveFilesystemReadAccess } = await import("@t3tools/client-runtime/state/filesystem");
32 return {
33 useFilesystemReadAccess: () =>
34 resolveFilesystemReadAccess({
35 isCatalogReady: true,
36 connection: { phase: state.phase, error: null },
37 session: state.session,
38 sessionError: null,
39 }),
40 };
41});
42vi.mock("~/state/assets", () => ({
43 assetEnvironment: { createUrl: state.assetQuery },
44}));
45vi.mock("~/state/use-atom-query-runner", () => ({ useAtomQueryRunner: () => state.mint }));
46
47import { useAssetUrlRefresh, useAssetUrlState } from "./assetUrls";
48
49const environmentId = EnvironmentId.make("asset-environment");
50const threadId = ThreadId.make("asset-thread");
51const resource = { _tag: "media-file", threadId, path: "/repo/image.png" } as const;
52
53beforeEach(() => {
54 state.session = null;
55 state.phase = "connected";
56 state.assetQuery.mockReset().mockReturnValue(state.assetAtom);
57 state.mint
58 .mockReset()
59 .mockResolvedValue(AsyncResult.success({ relativeUrl: "/api/assets/image.png", expiresAt: 1 }));
60});
61
62it.each(["workspace-file", "media-file"] as const)(
63 "keeps %s loading until its file grant resolves",
64 (_tag) => {
65 expect(useAssetUrlState(environmentId, { ...resource, _tag })).toEqual({ _tag: "Loading" });
66 expect(state.assetQuery).not.toHaveBeenCalled();
67
68 state.session = { authenticated: true, scopes: [AuthFilesystemReadScope] };
69 expect(useAssetUrlState(environmentId, { ...resource, _tag })).toEqual({
70 _tag: "Success",
71 expiresAt: 1,
72 url: "https://host.test/api/assets/image.png",
73 });
74 },
75);
76
77it("hides host assets with a denied grant while preserving attachments", () => {
78 state.session = { authenticated: true, scopes: [] };
79 expect(useAssetUrlState(environmentId, resource)).toEqual({
80 _tag: "Failure",
81 reason: "missing-file-access",
82 });
83 expect(state.assetQuery).not.toHaveBeenCalled();
84 expect(useAssetUrlState(environmentId, { _tag: "attachment", attachmentId: "upload" })).toEqual({
85 _tag: "Success",
86 expiresAt: 1,
87 url: "https://host.test/api/assets/image.png",
88 });
89});
90
91it("stops waiting for an unresolved grant when the connection is offline", () => {
92 state.phase = "offline";
93 expect(useAssetUrlState(environmentId, resource)).toEqual({ _tag: "Failure" });
94 expect(state.assetQuery).not.toHaveBeenCalled();
95});
96
97it("lets the server authorize an explicit refresh before the client grant loads", async () => {
98 await expect(useAssetUrlRefresh(environmentId, resource)()).resolves.toBe(
99 "https://host.test/api/assets/image.png",
100 );
101 expect(state.mint).toHaveBeenCalledWith({ environmentId, input: { resource } });
102
103 const denied = new EnvironmentAuthorizationError({
104 message: "This connection cannot read host files.",
105 requiredScope: AuthFilesystemReadScope,
106 });
107 state.mint.mockResolvedValue(AsyncResult.failure(Cause.fail(denied)));
108 await expect(useAssetUrlRefresh(environmentId, resource)()).rejects.toBe(denied);
109});