Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 16 additions & 1 deletion apps/cyberstorm-remix/app/entry.client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,20 @@ import { hydrateRoot } from "react-dom/client";
import { useLocation, useMatches } from "react-router";
import { HydratedRouter } from "react-router/dom";

import { getPublicEnvVariables } from "cyberstorm/security/publicEnvVariables";
import {
getPublicEnvVariables,
getSessionTools,
} from "cyberstorm/security/publicEnvVariables";
import { denyUrls } from "cyberstorm/utils/sentry";
import { initializeClientDapper } from "cyberstorm/utils/dapperSingleton";

const publicEnvVariables = getPublicEnvVariables([
"VITE_SITE_URL",
"VITE_BETA_SITE_URL",
"VITE_API_URL",
"VITE_AUTH_BASE_URL",
"VITE_CLIENT_SENTRY_DSN",
"VITE_COOKIE_DOMAIN",
]);

Sentry.init({
Expand Down Expand Up @@ -69,6 +74,16 @@ Sentry.init({
denyUrls,
});

try {
const sessionTools = getSessionTools();

initializeClientDapper(() =>
sessionTools.getConfig(publicEnvVariables.VITE_API_URL)
);
} catch (error) {
Sentry.captureException(error);
}

startTransition(() => {
hydrateRoot(
document,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { getSessionContext } from "@thunderstore/ts-api-react/src/SessionContext";
import { isRecord } from "cyberstorm/utils/typeChecks";
import { isRecord } from "../utils/typeChecks";

export type publicEnvVariablesKeys =
| "SITE_URL"
Expand Down Expand Up @@ -57,7 +57,7 @@ export function getSessionTools() {
!publicEnvVariables.VITE_COOKIE_DOMAIN
) {
throw new Error(
"Enviroment variables did not load correctly, please hard refresh page"
"Environment variables did not load correctly, please hard refresh page"
);
}
return getSessionContext(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import {
initializeClientDapper,
getClientDapper,
getDapperForRequest,
resetDapperSingletonForTest,
} from "../dapperSingleton";
import { deduplicatePromiseForRequest } from "../requestCache";
import { DapperTs } from "@thunderstore/dapper-ts";
import * as publicEnvVariables from "../../security/publicEnvVariables";
import type { Community } from "../../../../../packages/thunderstore-api/src";

// Mock getSessionTools
vi.mock("../../security/publicEnvVariables", () => ({
getSessionTools: vi.fn().mockReturnValue({
getConfig: vi.fn().mockReturnValue({
apiHost: "http://localhost",
sessionId: "test-session",
}),
}),
}));

describe("dapperSingleton", () => {
beforeEach(() => {
// Reset window.Dapper
if (typeof window !== "undefined") {
// @ts-expect-error Dapper is not optional on Window
delete window.Dapper;
}
resetDapperSingletonForTest();
vi.clearAllMocks();
});

afterEach(() => {
vi.restoreAllMocks();
});

describe("initializeClientDapper", () => {
it("initializes window.Dapper if it does not exist", () => {
initializeClientDapper();
expect(window.Dapper).toBeDefined();
expect(window.Dapper).toBeInstanceOf(DapperTs);
});

it("uses provided factory if supplied", () => {
const factory = vi.fn().mockReturnValue({ apiHost: "custom" });
initializeClientDapper(factory);
expect(window.Dapper).toBeDefined();
expect(window.Dapper.config()).toEqual({ apiHost: "custom" });
expect(factory).toHaveBeenCalled();
});

it("updates existing window.Dapper config if called again with factory", () => {
// First initialization
initializeClientDapper();
const originalDapper = window.Dapper;
expect(originalDapper).toBeDefined();

// Second initialization with new factory
const newFactory = vi.fn().mockReturnValue({ apiHost: "updated" });
initializeClientDapper(newFactory);

expect(window.Dapper).toBe(originalDapper); // Should be same instance
expect(window.Dapper.config()).toEqual({ apiHost: "updated" });
});

it("resolves config factory from session tools if no factory provided", () => {
initializeClientDapper();
expect(publicEnvVariables.getSessionTools).toHaveBeenCalled();
});
});

describe("getClientDapper", () => {
it("returns window.Dapper if it exists", () => {
initializeClientDapper();
const dapper = window.Dapper;
expect(getClientDapper()).toBe(dapper);
});

it("initializes and returns window.Dapper if it does not exist", () => {
expect(window.Dapper).toBeUndefined();
const dapper = getClientDapper();
expect(dapper).toBeDefined();
expect(window.Dapper).toBe(dapper);
});
});

describe("getDapperForRequest", () => {
it("returns client dapper if no request is provided", () => {
initializeClientDapper();
const dapper = getDapperForRequest();
expect(dapper).toBe(window.Dapper);
});

it("returns a proxy if request is provided", () => {
initializeClientDapper();
const request = new Request("http://localhost");
const dapper = getDapperForRequest(request);
expect(dapper).not.toBe(window.Dapper);
// It should be a proxy
expect(dapper).toBeInstanceOf(DapperTs);
});

it("caches the proxy for the same request", () => {
initializeClientDapper();
const request = new Request("http://localhost");
const dapper1 = getDapperForRequest(request);
const dapper2 = getDapperForRequest(request);
expect(dapper1).toBe(dapper2);
});

it("creates different proxies for different requests", () => {
initializeClientDapper();
const request1 = new Request("http://localhost");
const request2 = new Request("http://localhost");
const dapper1 = getDapperForRequest(request1);
const dapper2 = getDapperForRequest(request2);
expect(dapper1).not.toBe(dapper2);
});

it("intercepts 'get' methods and caches promises", async () => {
initializeClientDapper();
const request = new Request("http://localhost");
const dapper = getDapperForRequest(request);

// Mock the underlying method on window.Dapper
const mockGetCommunities = vi
.spyOn(window.Dapper, "getCommunities")
.mockResolvedValue({ count: 0, results: [], hasMore: false });

const result1 = await dapper.getCommunities();
const result2 = await dapper.getCommunities();

expect(result1).toEqual({ count: 0, results: [], hasMore: false });
expect(result2).toEqual({ count: 0, results: [], hasMore: false });

// Should be called only once due to caching
expect(mockGetCommunities).toHaveBeenCalledTimes(1);
});

it("does not intercept non-'get' methods", async () => {
initializeClientDapper();
const request = new Request("http://localhost");
const dapper = getDapperForRequest(request);

// Mock a non-get method
// postTeamCreate is a good candidate
const mockPostTeamCreate = vi
.spyOn(window.Dapper, "postTeamCreate")
.mockResolvedValue({
identifier: 1,
name: "test",
donation_link: null,
});

await dapper.postTeamCreate("test");
await dapper.postTeamCreate("test");

// Should be called twice (no caching)
expect(mockPostTeamCreate).toHaveBeenCalledTimes(2);
});

it("shares cache between proxy calls and manual deduplicatePromiseForRequest calls", async () => {
initializeClientDapper();
const request = new Request("http://localhost");
const dapper = getDapperForRequest(request);

// Mock the underlying method on window.Dapper
const mockGetCommunity = vi
.spyOn(window.Dapper, "getCommunity")
.mockResolvedValue({
identifier: "1",
name: "Test Community",
} as Community);

// 1. Call via proxy
const dapperResult = await dapper.getCommunity("1");

// 2. Call manually with same key and args
const manualFunc = vi.fn().mockResolvedValue("manual result");
const manualResult = await deduplicatePromiseForRequest(
"getCommunity",
manualFunc,
["1"],
request
);

// Assertions
expect(dapperResult).toEqual({ identifier: "1", name: "Test Community" });
// Should return the cached result from the first call, NOT "manual result"
expect(manualResult).toBe(dapperResult);
// The manual function should NOT have been called
expect(manualFunc).not.toHaveBeenCalled();
// The underlying dapper method should have been called once
expect(mockGetCommunity).toHaveBeenCalledTimes(1);
});
});
});
Loading
Loading