// @vitest-environment jsdom
import type { LoaderFunctionArgs } from "react-router";
import {
vi,
test,
describe,
beforeAll,
beforeEach,
afterEach,
expect,
Mock,
} from "vitest";
import { requireAuth } from "~/lib/auth";
import "vitest-dom/extend-expect";
import { createRoutesStub } from "react-router";
import { render, screen, waitFor } from "@testing-library/react";
import Dashboard, { loader } from "../dashboard";
import { AnalyticsEngineAPI } from "~/analytics/query";
import { createFetchResponse, getDefaultContext } from "./testutils";
import ResizeObserverPolyfill from "resize-observer-polyfill";
vi.mock("~/lib/auth", () => ({
requireAuth: vi.fn(),
}));
describe("Dashboard route", () => {
let fetch: Mock;
beforeAll(() => {
// polyfill needed for recharts (used by TimeSeriesChart)
global.ResizeObserver = ResizeObserverPolyfill;
});
beforeEach(() => {
fetch = global.fetch = vi.fn();
vi.mocked(requireAuth).mockResolvedValue({} as any);
});
afterEach(() => {
vi.restoreAllMocks();
});
describe("loader", () => {
test("throws a 501 Response if no Cloudflare credentials are provided", async () => {
const mockLoaderParams: LoaderFunctionArgs = {
context: {
analyticsEngine: new AnalyticsEngineAPI(
"testAccountId",
"testApiToken",
),
cloudflare: {
env: {
CF_ACCOUNT_ID: "",
CF_BEARER_TOKEN: "",
},
},
},
// @ts-expect-error we don't need to provide all the properties of the cloudflare object
request: {
url: "http://localhost:3000/dashboard",
},
};
try {
await loader(mockLoaderParams);
} catch (error) {
expect(error).toBeInstanceOf(Response);
const response = error as Response;
expect(await response.text()).toBe(
"Missing credentials: CF_ACCOUNT_ID is not set.",
);
expect(response.status).toBe(501);
}
// run it again, this time with account ID present, but bearer token absent
mockLoaderParams.context.cloudflare = {
env: {
CF_ACCOUNT_ID: "testAccountId",
CF_BEARER_TOKEN: "",
},
};
try {
await loader(mockLoaderParams);
} catch (error) {
expect(error).toBeInstanceOf(Response);
const response = error as Response;
expect(await response.text()).toBe(
"Missing credentials: CF_BEARER_TOKEN is not set.",
);
expect(response.status).toBe(501);
}
});
test("redirects to ?site=siteId if no siteId is provided via query string", async () => {
// response for getSitesByOrderedHits
fetch.mockResolvedValueOnce(
createFetchResponse({
data: [{ siteId: "test-siteid", count: 1 }],
}),
);
try {
await loader({
...getDefaultContext(),
// @ts-expect-error we don't need to provide all the properties of the request object
request: {
url: "http://localhost:3000/dashboard", // no site query param
},
});
} catch (error) {
expect(error).toBeInstanceOf(Response);
const response = error as Response;
// expect redirect
expect(response.status).toBe(302);
expect(response.headers.get("Location")).toBe(
"http://localhost:3000/dashboard?site=test-siteid",
);
}
});
test("redirects to ?site= if no siteId is provided via query string / no site data", async () => {
// response for getSitesByOrderedHits
fetch.mockResolvedValueOnce(
createFetchResponse({
data: [],
}),
);
try {
await loader({
...getDefaultContext(),
// @ts-expect-error we don't need to provide all the properties of the request object
request: {
url: "http://localhost:3000/dashboard", // no site query param
},
});
} catch (error) {
expect(error).toBeInstanceOf(Response);
const response = error as Response;
// expect redirect
expect(response.status).toBe(302);
expect(response.headers.get("Location")).toBe(
"http://localhost:3000/dashboard?site=",
);
}
});
test("assembles data returned from CF API", async () => {
// response for getSitesByOrderedHits
fetch.mockResolvedValueOnce(
createFetchResponse({
data: [{ siteId: "test-siteid", count: 1 }],
}),
);
vi.setSystemTime(new Date("2024-01-18T09:33:02").getTime());
const response = await loader({
...getDefaultContext(),
// @ts-expect-error we don't need to provide all the properties of the request object
request: {
url: "http://localhost:3000/dashboard?site=test-siteid",
},
});
const json = await response;
expect(json).toEqual({
filters: {},
siteId: "test-siteid",
sites: ["test-siteid"],
intervalType: "DAY",
interval: "7d",
});
});
test("returns a valid empty result set when no data (no sites, no anything)", async () => {
vi.setSystemTime(new Date("2024-01-18T09:33:02").getTime());
fetch.mockResolvedValueOnce(createFetchResponse({ data: [] })); // getSitesOrderedByHits
fetch.mockResolvedValueOnce(createFetchResponse({ data: [] })); // get counts
fetch.mockResolvedValueOnce(createFetchResponse({ data: [] })); // getViewsGroupedByInterval
const response = await loader({
...getDefaultContext(),
// @ts-expect-error we don't need to provide all the properties of the request object
request: {
url: "http://localhost:3000/dashboard?site=", // intentionally empty
},
});
const json = await response;
expect(json).toEqual({
filters: {},
siteId: "",
sites: [],
intervalType: "DAY",
interval: "7d",
});
});
});
test("renders when no data", async () => {
function loader() {
return {
siteId: "@unknown",
sites: [],
intervalType: "day",
};
}
const RemixStub = createRoutesStub([
{
path: "/",
Component: Dashboard,
loader,
children: [
{
path: "/resources/timeseries",
loader: () => {
return { chartData: [] };
},
},
{
path: "/resources/stats",
loader: () => {
return {
views: 0,
visitors: 0,
};
},
},
{
path: "/resources/paths",
loader: () => {
return { countsByProperty: [] };
},
},
{
path: "/resources/referrer",
loader: () => {
return { countsByProperty: [] };
},
},
{
path: "/resources/browser",
loader: () => {
return { countsByProperty: [] };
},
},
{
path: "/resources/country",
loader: () => {
return { countsByProperty: [] };
},
},
{
path: "/resources/device",
loader: () => {
return { countsByProperty: [] };
},
},
{
path: "/resources/browserversion",
loader: () => {
return { countsByProperty: [] };
},
},
{
path: "/resources/utm-source",
loader: () => {
return { countsByProperty: [] };
},
},
{
path: "/resources/utm-medium",
loader: () => {
return { countsByProperty: [] };
},
},
{
path: "/resources/utm-campaign",
loader: () => {
return { countsByProperty: [] };
},
},
{
path: "/resources/utm-term",
loader: () => {
return { countsByProperty: [] };
},
},
{
path: "/resources/utm-content",
loader: () => {
return { countsByProperty: [] };
},
},
],
},
]);
render();
await waitFor(() => screen.findByText("Path"));
expect(screen.getByText("Path")).toBeInTheDocument();
expect(screen.getByText("Referrer")).toBeInTheDocument();
expect(screen.getByText("Browser")).toBeInTheDocument();
expect(screen.getByText("Country")).toBeInTheDocument();
expect(screen.getByText("Device")).toBeInTheDocument();
expect(screen.getByText("UTM Source")).toBeInTheDocument();
expect(screen.getByText("UTM Medium")).toBeInTheDocument();
expect(screen.getByText("UTM Campaign")).toBeInTheDocument();
expect(screen.getByText("UTM Term")).toBeInTheDocument();
expect(screen.getByText("UTM Content")).toBeInTheDocument();
});
const defaultMockedLoaderJson = {
siteId: "example",
sites: ["example"],
views: 2133,
visitors: 33,
viewsGroupedByInterval: [
["2024-01-11 05:00:00", 0],
["2024-01-12 05:00:00", 0],
["2024-01-13 05:00:00", 3],
["2024-01-14 05:00:00", 0],
["2024-01-15 05:00:00", 0],
["2024-01-16 05:00:00", 2],
["2024-01-17 05:00:00", 1],
["2024-01-18 05:00:00", 0],
],
intervalType: "day",
filters: {
path: "/lol",
},
};
test("renders with valid data", async () => {
function loader() {
return {
...defaultMockedLoaderJson,
};
}
const RemixStub = createRoutesStub([
{
path: "/",
Component: Dashboard,
loader,
children: [
{
path: "/resources/stats",
loader: () => {
return {
views: 2133,
visitors: 33,
};
},
},
{
path: "/resources/timeseries",
loader: () => {
return {};
},
},
{
path: "/resources/paths",
loader: () => {
return {
countsByProperty: [
["/", 100],
["/about", 80],
["/contact", 60],
],
};
},
},
{
path: "/resources/referrer",
loader: () => {
return {
countsByProperty: [
["google.com", 100],
["facebook.com", 80],
["twitter.com", 60],
],
};
},
},
{
path: "/resources/browser",
loader: () => {
return {
countsByProperty: [
["Chrome", 100],
["Safari", 80],
["Firefox", 60],
],
};
},
},
{
path: "/resources/country",
loader: () => {
return {
countsByProperty: [
["United States", 100],
["Canada", 80],
["United Kingdom", 60],
],
};
},
},
{
path: "/resources/device",
loader: () => {
return {
countsByProperty: [
["Desktop", 100],
["Mobile", 80],
["Tablet", 60],
],
};
},
},
{
path: "/resources/browserversion",
loader: () => {
return { countsByProperty: [] };
},
},
{
path: "/resources/utm-source",
loader: () => {
return { countsByProperty: [] };
},
},
{
path: "/resources/utm-medium",
loader: () => {
return { countsByProperty: [] };
},
},
{
path: "/resources/utm-campaign",
loader: () => {
return { countsByProperty: [] };
},
},
{
path: "/resources/utm-term",
loader: () => {
return { countsByProperty: [] };
},
},
{
path: "/resources/utm-content",
loader: () => {
return { countsByProperty: [] };
},
},
],
},
]);
render();
// wait until the rows render in the document
await waitFor(() => screen.findByText("Chrome"), {
// increased timeout because this test was failing on slower environments, e.g. GitHub actions
timeout: 5_000,
});
// assert some of the data we mocked actually rendered into the document
expect(screen.getByText("2.1K")).toBeInTheDocument(); // view count
expect(screen.getByText("33")).toBeInTheDocument(); // visitor count
expect(screen.getByText("/about")).toBeInTheDocument();
expect(screen.getByText("Chrome")).toBeInTheDocument();
expect(screen.getByText("google.com")).toBeInTheDocument();
expect(screen.getByText("Canada")).toBeInTheDocument(); // assert converted CA -> Canada
expect(screen.getByText("Mobile")).toBeInTheDocument();
});
});