Skip to content

Commit 2fd3433

Browse files
committed
Add dapperSingleton module with client initialization and request handling
1 parent b3d56ff commit 2fd3433

File tree

2 files changed

+247
-0
lines changed

2 files changed

+247
-0
lines changed
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2+
import {
3+
initializeClientDapper,
4+
getClientDapper,
5+
getRequestScopedDapper,
6+
resetDapperSingletonForTest,
7+
} from "../dapperSingleton";
8+
import { DapperTs } from "@thunderstore/dapper-ts";
9+
import * as publicEnvVariables from "../../security/publicEnvVariables";
10+
11+
// Mock getSessionTools
12+
vi.mock("../../security/publicEnvVariables", () => ({
13+
getSessionTools: vi.fn().mockReturnValue({
14+
getConfig: vi.fn().mockReturnValue({
15+
apiHost: "http://localhost",
16+
sessionId: "test-session",
17+
}),
18+
}),
19+
}));
20+
21+
describe("dapperSingleton", () => {
22+
beforeEach(() => {
23+
// Reset window.Dapper
24+
if (typeof window !== "undefined") {
25+
// @ts-expect-error Dapper is not optional on Window
26+
delete window.Dapper;
27+
}
28+
resetDapperSingletonForTest();
29+
vi.clearAllMocks();
30+
});
31+
32+
afterEach(() => {
33+
vi.restoreAllMocks();
34+
});
35+
36+
describe("initializeClientDapper", () => {
37+
it("initializes window.Dapper if it does not exist", () => {
38+
initializeClientDapper();
39+
expect(window.Dapper).toBeDefined();
40+
expect(window.Dapper).toBeInstanceOf(DapperTs);
41+
});
42+
43+
it("uses provided factory if supplied", () => {
44+
const factory = vi.fn().mockReturnValue({ apiHost: "custom" });
45+
initializeClientDapper(factory);
46+
expect(window.Dapper).toBeDefined();
47+
expect(window.Dapper.config()).toEqual({ apiHost: "custom" });
48+
expect(factory).toHaveBeenCalled();
49+
});
50+
51+
it("resolves config factory from session tools if no factory provided", () => {
52+
initializeClientDapper();
53+
expect(publicEnvVariables.getSessionTools).toHaveBeenCalled();
54+
});
55+
});
56+
57+
describe("getClientDapper", () => {
58+
it("returns window.Dapper if it exists", () => {
59+
initializeClientDapper();
60+
const dapper = window.Dapper;
61+
expect(getClientDapper()).toBe(dapper);
62+
});
63+
64+
it("initializes and returns window.Dapper if it does not exist", () => {
65+
expect(window.Dapper).toBeUndefined();
66+
const dapper = getClientDapper();
67+
expect(dapper).toBeDefined();
68+
expect(window.Dapper).toBe(dapper);
69+
});
70+
});
71+
72+
describe("getRequestScopedDapper", () => {
73+
it("returns client dapper if no request is provided", () => {
74+
initializeClientDapper();
75+
const dapper = getRequestScopedDapper();
76+
expect(dapper).toBe(window.Dapper);
77+
});
78+
79+
it("returns a proxy if request is provided", () => {
80+
initializeClientDapper();
81+
const request = new Request("http://localhost");
82+
const dapper = getRequestScopedDapper(request);
83+
expect(dapper).not.toBe(window.Dapper);
84+
// It should be a proxy
85+
expect(dapper).toBeInstanceOf(DapperTs);
86+
});
87+
88+
it("caches the proxy for the same request", () => {
89+
initializeClientDapper();
90+
const request = new Request("http://localhost");
91+
const dapper1 = getRequestScopedDapper(request);
92+
const dapper2 = getRequestScopedDapper(request);
93+
expect(dapper1).toBe(dapper2);
94+
});
95+
96+
it("creates different proxies for different requests", () => {
97+
initializeClientDapper();
98+
const request1 = new Request("http://localhost");
99+
const request2 = new Request("http://localhost");
100+
const dapper1 = getRequestScopedDapper(request1);
101+
const dapper2 = getRequestScopedDapper(request2);
102+
expect(dapper1).not.toBe(dapper2);
103+
});
104+
105+
it("intercepts 'get' methods and caches promises", async () => {
106+
initializeClientDapper();
107+
const request = new Request("http://localhost");
108+
const dapper = getRequestScopedDapper(request);
109+
110+
// Mock the underlying method on window.Dapper
111+
const mockGetCommunities = vi
112+
.spyOn(window.Dapper, "getCommunities")
113+
.mockResolvedValue({ count: 0, results: [], hasMore: false });
114+
115+
const result1 = await dapper.getCommunities();
116+
const result2 = await dapper.getCommunities();
117+
118+
expect(result1).toEqual({ count: 0, results: [], hasMore: false });
119+
expect(result2).toEqual({ count: 0, results: [], hasMore: false });
120+
121+
// Should be called only once due to caching
122+
expect(mockGetCommunities).toHaveBeenCalledTimes(1);
123+
});
124+
125+
it("does not intercept non-'get' methods", async () => {
126+
initializeClientDapper();
127+
const request = new Request("http://localhost");
128+
const dapper = getRequestScopedDapper(request);
129+
130+
// Mock a non-get method
131+
// postTeamCreate is a good candidate
132+
const mockPostTeamCreate = vi
133+
.spyOn(window.Dapper, "postTeamCreate")
134+
.mockResolvedValue({
135+
identifier: 1,
136+
name: "test",
137+
donation_link: null,
138+
});
139+
140+
await dapper.postTeamCreate("test");
141+
await dapper.postTeamCreate("test");
142+
143+
// Should be called twice (no caching)
144+
expect(mockPostTeamCreate).toHaveBeenCalledTimes(2);
145+
});
146+
});
147+
});
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import { DapperTs } from "@thunderstore/dapper-ts";
2+
import { type RequestConfig } from "@thunderstore/thunderstore-api";
3+
import { getSessionTools } from "../security/publicEnvVariables";
4+
import { getCachedRequestPromise } from "./requestCache";
5+
6+
type ConfigFactory = () => RequestConfig;
7+
8+
let currentConfigFactory: ConfigFactory | undefined;
9+
const requestScopedProxies = new WeakMap<Request, DapperTs>();
10+
11+
function resolveConfigFactory(): ConfigFactory {
12+
if (currentConfigFactory) {
13+
return currentConfigFactory;
14+
}
15+
16+
const tools = getSessionTools();
17+
currentConfigFactory = () => tools.getConfig();
18+
return currentConfigFactory;
19+
}
20+
21+
function updateDapperConfig(factory: ConfigFactory) {
22+
currentConfigFactory = factory;
23+
if (typeof window !== "undefined" && window.Dapper) {
24+
window.Dapper.config = () => factory();
25+
}
26+
}
27+
28+
export function initializeClientDapper(factory?: ConfigFactory) {
29+
if (typeof window === "undefined") {
30+
return;
31+
}
32+
33+
if (factory) {
34+
updateDapperConfig(factory);
35+
} else if (!currentConfigFactory) {
36+
resolveConfigFactory();
37+
}
38+
39+
if (!window.Dapper) {
40+
const resolvedFactory = resolveConfigFactory();
41+
window.Dapper = new DapperTs(() => resolvedFactory());
42+
}
43+
}
44+
45+
export function getClientDapper(): DapperTs {
46+
if (typeof window === "undefined") {
47+
throw new Error("getClientDapper can only run in the browser");
48+
}
49+
50+
if (!window.Dapper) {
51+
initializeClientDapper();
52+
}
53+
54+
return window.Dapper;
55+
}
56+
57+
export function getRequestScopedDapper(request?: Request): DapperTs {
58+
if (!request) {
59+
return getClientDapper();
60+
}
61+
62+
let proxy = requestScopedProxies.get(request);
63+
if (proxy) {
64+
return proxy;
65+
}
66+
67+
const baseDapper = getClientDapper();
68+
const handler: ProxyHandler<DapperTs> = {
69+
get(target, propertyName, receiver) {
70+
const value = Reflect.get(target, propertyName, receiver);
71+
if (typeof value !== "function") {
72+
return value;
73+
}
74+
75+
if (typeof propertyName !== "string" || !propertyName.startsWith("get")) {
76+
return value.bind(target);
77+
}
78+
79+
return (...args: unknown[]) =>
80+
getCachedRequestPromise(
81+
propertyName,
82+
(...innerArgs: unknown[]) =>
83+
(value as (...i: unknown[]) => Promise<unknown>).apply(
84+
target,
85+
innerArgs
86+
),
87+
args as unknown[],
88+
request
89+
);
90+
},
91+
};
92+
93+
proxy = new Proxy(baseDapper, handler) as DapperTs;
94+
requestScopedProxies.set(request, proxy);
95+
return proxy;
96+
}
97+
98+
export function resetDapperSingletonForTest() {
99+
currentConfigFactory = undefined;
100+
}

0 commit comments

Comments
 (0)