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("./session", () => ({
28 environmentSession: { sessionStateAtom: () => ({}) },
29 usePreparedConnection: () => ({ _tag: "Some", value: { httpBaseUrl: "https://host.test" } }),
30}));
31vi.mock("./presentation", () => ({
32 useEnvironmentPresentation: () => ({
33 isReady: true,
34 presentation: { connection: { phase: state.phase, error: null } },
35 }),
36}));
37vi.mock("./query", () => ({
38 useEnvironmentQuery: () => ({ data: state.session, error: null }),
39}));
40vi.mock("./projectClones", () => ({ environmentProjectCloneListAtom: () => null }));
41vi.mock("../connection/runtime", () => ({ connectionAtomRuntime: {} }));
42vi.mock("@t3tools/client-runtime/state/assets", async (importOriginal) => ({
43 ...(await importOriginal<typeof import("@t3tools/client-runtime/state/assets")>()),
44 createAssetEnvironmentAtoms: () => ({ createUrl: state.assetQuery }),
45}));
46vi.mock("./use-atom-query-runner", () => ({ useAtomQueryRunner: () => state.mint }));
47
48import { useRefreshAssetUrl, useAssetUrlState } from "./assets";
49
50const environmentId = EnvironmentId.make("asset-environment");
51const threadId = ThreadId.make("asset-thread");
52const resource = { _tag: "media-file", threadId, path: "/repo/image.png" } as const;
53
54beforeEach(() => {
55 state.session = null;
56 state.phase = "connected";
57 state.assetQuery.mockReset().mockReturnValue(state.assetAtom);
58 state.mint
59 .mockReset()
60 .mockResolvedValue(AsyncResult.success({ relativeUrl: "/api/assets/image.png", expiresAt: 1 }));
61});
62
63it.each(["workspace-file", "media-file"] as const)(
64 "keeps %s loading until its file grant resolves",
65 (_tag) => {
66 expect(useAssetUrlState(environmentId, { ...resource, _tag })).toEqual({ _tag: "Loading" });
67 expect(state.assetQuery).not.toHaveBeenCalled();
68
69 state.session = { authenticated: true, scopes: [AuthFilesystemReadScope] };
70 expect(useAssetUrlState(environmentId, { ...resource, _tag })).toEqual({
71 _tag: "Success",
72 expiresAt: 1,
73 url: "https://host.test/api/assets/image.png",
74 });
75 },
76);
77
78it("hides host assets with a denied grant while preserving attachments", () => {
79 state.session = { authenticated: true, scopes: [] };
80 expect(useAssetUrlState(environmentId, resource)).toEqual({
81 _tag: "Failure",
82 reason: "missing-file-access",
83 });
84 expect(state.assetQuery).not.toHaveBeenCalled();
85 expect(useAssetUrlState(environmentId, { _tag: "attachment", attachmentId: "upload" })).toEqual({
86 _tag: "Success",
87 expiresAt: 1,
88 url: "https://host.test/api/assets/image.png",
89 });
90});
91
92it("stops waiting for an unresolved grant when the connection is offline", () => {
93 state.phase = "offline";
94 expect(useAssetUrlState(environmentId, resource)).toEqual({
95 _tag: "Failure",
96 reason: "disconnected",
97 });
98 expect(state.assetQuery).not.toHaveBeenCalled();
99});
100
101it("lets the server authorize an explicit refresh before the client grant loads", async () => {
102 await expect(useRefreshAssetUrl(environmentId, resource)()).resolves.toBe(
103 "https://host.test/api/assets/image.png",
104 );
105 expect(state.mint).toHaveBeenCalledWith({ environmentId, input: { resource } });
106
107 const denied = new EnvironmentAuthorizationError({
108 message: "This connection cannot read host files.",
109 requiredScope: AuthFilesystemReadScope,
110 });
111 state.mint.mockResolvedValue(AsyncResult.failure(Cause.fail(denied)));
112 await expect(useRefreshAssetUrl(environmentId, resource)()).resolves.toBeNull();
113});