1import {
2 AssetWorkspaceAssetNotFoundError,
3 AuthFilesystemReadScope,
4 EnvironmentAuthorizationError,
5 EnvironmentId,
6 ThreadId,
7 type AuthSessionState,
8} from "@t3tools/contracts";
9import * as Cause from "effect/Cause";
10import { Atom, AsyncResult } from "effect/reactivity";
11import { beforeEach, expect, it, vi } from "vite-plus/test";
12
13const state = vi.hoisted(() => ({
14 session: null as Pick<AuthSessionState, "authenticated" | "scopes"> | null,
15 phase: "connected" as "connected" | "offline",
16 assetAtom: {},
17 mint: vi.fn(),
18 assetQuery: vi.fn(),
19 executeQuery: vi.fn(),
20 preparedConnectionAtom: null as unknown,
21}));
22
23vi.mock("react", () => ({ useCallback: <A>(callback: A) => callback }));
24vi.mock("@effect/atom-react", () => ({
25 useAtomMount: () => undefined,
26 useAtomSubscribe: () => undefined,
27 useAtomValue: (atom: unknown) =>
28 atom === state.assetAtom
29 ? AsyncResult.success({ relativeUrl: "/api/assets/image.png", expiresAt: 1 })
30 : AsyncResult.initial(false),
31}));
32vi.mock("./session", () => ({
33 environmentSession: {
34 sessionStateAtom: () => ({}),
35 preparedConnectionValueAtom: () => state.preparedConnectionAtom,
36 },
37 usePreparedConnection: () => ({ _tag: "Some", value: { httpBaseUrl: "https://host.test" } }),
38}));
39vi.mock("./presentation", () => ({
40 useEnvironmentPresentation: () => ({
41 isReady: true,
42 presentation: { connection: { phase: state.phase, error: null } },
43 }),
44}));
45vi.mock("./query", () => ({
46 useEnvironmentQuery: () => ({ data: state.session, error: null }),
47}));
48vi.mock("./projectClones", () => ({ environmentProjectCloneListAtom: () => null }));
49vi.mock("../connection/runtime", () => ({ connectionAtomRuntime: {} }));
50vi.mock("../connection/catalog", () => ({
51 environmentCatalog: { sessionGenerationAtom: () => null },
52}));
53vi.mock("@t3tools/client-runtime/state/assets", async (importOriginal) => ({
54 ...(await importOriginal<typeof import("@t3tools/client-runtime/state/assets")>()),
55 createAssetEnvironmentAtoms: () => ({ createUrl: state.assetQuery }),
56}));
57vi.mock("@t3tools/client-runtime/state/runtime", async (importOriginal) => ({
58 ...(await importOriginal<typeof import("@t3tools/client-runtime/state/runtime")>()),
59 executeAtomQuery: state.executeQuery,
60}));
61vi.mock("./use-atom-query-runner", () => ({ useAtomQueryRunner: () => state.mint }));
62
63import { refreshAssetUrl, useRefreshAssetUrl, useAssetUrlState } from "./assets";
64
65const environmentId = EnvironmentId.make("asset-environment");
66const threadId = ThreadId.make("asset-thread");
67const resource = { _tag: "media-file", threadId, path: "/repo/image.png" } as const;
68
69beforeEach(() => {
70 state.session = null;
71 state.phase = "connected";
72 state.assetQuery.mockReset().mockReturnValue(state.assetAtom);
73 state.mint
74 .mockReset()
75 .mockResolvedValue(AsyncResult.success({ relativeUrl: "/api/assets/image.png", expiresAt: 1 }));
76});
77
78it.each(["workspace-file", "media-file"] as const)(
79 "keeps %s loading until its file grant resolves",
80 (_tag) => {
81 expect(useAssetUrlState(environmentId, { ...resource, _tag })).toEqual({ _tag: "Loading" });
82 expect(state.assetQuery).not.toHaveBeenCalled();
83
84 state.session = { authenticated: true, scopes: [AuthFilesystemReadScope] };
85 expect(useAssetUrlState(environmentId, { ...resource, _tag })).toEqual({
86 _tag: "Success",
87 expiresAt: 1,
88 url: "https://host.test/api/assets/image.png",
89 });
90 },
91);
92
93it("hides host assets with a denied grant while preserving attachments", () => {
94 state.session = { authenticated: true, scopes: [] };
95 expect(useAssetUrlState(environmentId, resource)).toEqual({
96 _tag: "Failure",
97 reason: "missing-file-access",
98 });
99 expect(state.assetQuery).not.toHaveBeenCalled();
100 expect(useAssetUrlState(environmentId, { _tag: "attachment", attachmentId: "upload" })).toEqual({
101 _tag: "Success",
102 expiresAt: 1,
103 url: "https://host.test/api/assets/image.png",
104 });
105});
106
107it("lets the server authorize an explicit refresh before the client grant loads", async () => {
108 await expect(useRefreshAssetUrl(environmentId, resource)()).resolves.toBe(
109 "https://host.test/api/assets/image.png",
110 );
111 expect(state.mint).toHaveBeenCalledWith({ environmentId, input: { resource } });
112
113 const denied = new EnvironmentAuthorizationError({
114 message: "This connection cannot read host files.",
115 requiredScope: AuthFilesystemReadScope,
116 });
117 state.mint.mockResolvedValue(AsyncResult.failure(Cause.fail(denied)));
118 await expect(useRefreshAssetUrl(environmentId, resource)()).resolves.toBeNull();
119});
120
121it("rejects with the server's error and resolves null only without a connection", async () => {
122 const missing = new AssetWorkspaceAssetNotFoundError({
123 resource,
124 checkedPath: "/repo/missing.png",
125 });
126 state.executeQuery.mockResolvedValue(AsyncResult.failure(Cause.fail(missing)));
127 state.preparedConnectionAtom = Atom.make({
128 _tag: "Some",
129 value: { httpBaseUrl: "https://host.test" },
130 });
131 await expect(refreshAssetUrl(environmentId, resource)).rejects.toBe(missing);
132
133 state.preparedConnectionAtom = Atom.make({ _tag: "None" });
134 await expect(refreshAssetUrl(environmentId, resource)).resolves.toBeNull();
135});