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
221 changes: 221 additions & 0 deletions __tests__/admin/SearchAnalyticsPage.test.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,221 @@
/**
* Search analytics page — loading / data / error state tests (#326).
* -------------------------------------------------------------------
* The page renders query volume charts, CTR trend, top queries table,
* and zero-result queries list. Metrics the platform does not instrument
* yet must render an explicit "Not tracked yet" placeholder instead of
* a silent zero. These tests mock the analytics service and assert the
* presentational branching only.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";

vi.mock("@/lib/config/font.config", () => ({
poppins_400: { className: "" },
poppins_500: { className: "" },
poppins_600: { className: "" },
}));

const serviceState = vi.hoisted(() => ({ current: null }));
vi.mock("@/lib/actions/admin-search-analytics", () => ({
fetchSearchAnalytics: (...args) => serviceState.current(...args),
}));

function makeAnalytics(overrides = {}) {
return {
generatedAt: "2026-08-25T00:00:00.000Z",
dateRange: {
from: "2026-07-26T00:00:00.000Z",
to: "2026-08-25T00:00:00.000Z",
},
totals: {
totalQueries: { label: "Total Queries", value: 0, tracked: false },
uniqueQueries: { label: "Unique Queries", value: 0, tracked: false },
zeroResultQueries: {
label: "Zero-Result Queries",
value: 0,
tracked: false,
},
avgClickThroughRate: {
label: "Avg. Click-Through Rate",
value: null,
tracked: false,
unit: "%",
},
},
topQueries: [],
zeroResultQueries: [],
weeklyTrends: [
{ week: "Week 1", queries: 0, zeroResults: 0, clickThroughRate: null },
{ week: "Week 2", queries: 0, zeroResults: 0, clickThroughRate: null },
{ week: "Week 3", queries: 0, zeroResults: 0, clickThroughRate: null },
{ week: "Week 4", queries: 0, zeroResults: 0, clickThroughRate: null },
],
...overrides,
};
}

let SearchAnalyticsPage;
beforeEach(async () => {
if (!SearchAnalyticsPage) {
const mod = await import(
"@/app/[locale]/admin/analytics/search/page"
);
SearchAnalyticsPage = mod.default;
}
});

describe("SearchAnalyticsPage — states", () => {
it("renders skeleton placeholders while loading", () => {
serviceState.current = () => new Promise(() => {});
const { container } = render(<SearchAnalyticsPage />);
expect(screen.getByText("Search Analytics")).toBeInTheDocument();
expect(
container.querySelector('[data-slot="skeleton"]')
).not.toBeNull();
});

it("renders summary stat cards when data loads", async () => {
serviceState.current = () => Promise.resolve(makeAnalytics());
render(<SearchAnalyticsPage />);

expect(
await screen.findByText("Search Analytics")
).toBeInTheDocument();
expect(screen.getByText("Total Queries")).toBeInTheDocument();
expect(screen.getByText("Unique Queries")).toBeInTheDocument();
expect(
screen.getAllByText("Zero-Result Queries").length
).toBeGreaterThanOrEqual(1);
expect(
screen.getByText("Avg. Click-Through Rate")
).toBeInTheDocument();
});

it("shows 'Not tracked yet' placeholders for untracked metrics", async () => {
serviceState.current = () => Promise.resolve(makeAnalytics());
render(<SearchAnalyticsPage />);

await screen.findByText("Search Analytics");

const placeholders = screen.getAllByText(/not tracked yet/i);
// At least the 4 summary stat cards + charts + tables
expect(placeholders.length).toBeGreaterThanOrEqual(4);
});

it("renders the top queries and zero-result sections", async () => {
serviceState.current = () => Promise.resolve(makeAnalytics());
render(<SearchAnalyticsPage />);

await screen.findByText("Search Analytics");
expect(screen.getAllByText("Top Queries").length).toBeGreaterThanOrEqual(1);
expect(
screen.getAllByText("Zero-Result Queries").length
).toBeGreaterThanOrEqual(1);
});

it("renders top queries data when available", async () => {
const data = makeAnalytics({
topQueries: [
{
query: "quran tafsir",
count: 142,
clickThroughRate: 67.5,
trend: 12.3,
},
{
query: "arabic grammar",
count: 98,
clickThroughRate: 45.2,
trend: -5.1,
},
],
totals: {
totalQueries: { label: "Total Queries", value: 240, tracked: true },
uniqueQueries: { label: "Unique Queries", value: 87, tracked: true },
zeroResultQueries: {
label: "Zero-Result Queries",
value: 12,
tracked: true,
},
avgClickThroughRate: {
label: "Avg. Click-Through Rate",
value: 56.3,
tracked: true,
unit: "%",
},
},
});
serviceState.current = () => Promise.resolve(data);
render(<SearchAnalyticsPage />);

await screen.findByText("quran tafsir");
expect(screen.getByText("arabic grammar")).toBeInTheDocument();
expect(screen.getByText("142")).toBeInTheDocument();
expect(screen.getByText("98")).toBeInTheDocument();
expect(screen.getByText("67.5%")).toBeInTheDocument();
expect(screen.getByText("45.2%")).toBeInTheDocument();
expect(screen.getByText("240")).toBeInTheDocument();
expect(screen.getByText("87")).toBeInTheDocument();
expect(screen.getByText("56.3%")).toBeInTheDocument();
});

it("renders zero-result queries data when available", async () => {
const data = makeAnalytics({
zeroResultQueries: [
{
query: "advanced nahw exercises",
count: 8,
lastSearched: "2026-08-20T10:00:00.000Z",
},
],
totals: {
totalQueries: { label: "Total Queries", value: 240, tracked: true },
uniqueQueries: { label: "Unique Queries", value: 87, tracked: true },
zeroResultQueries: {
label: "Zero-Result Queries",
value: 12,
tracked: true,
},
avgClickThroughRate: {
label: "Avg. Click-Through Rate",
value: 56.3,
tracked: true,
unit: "%",
},
},
});
serviceState.current = () => Promise.resolve(data);
render(<SearchAnalyticsPage />);

await screen.findByText(/advanced nahw exercises/);
expect(screen.getByText("8")).toBeInTheDocument();
expect(
screen.getByText("advanced nahw exercises").closest("tr")
).toBeTruthy();
});

it("renders an error state when the analytics service fails", async () => {
serviceState.current = () =>
Promise.reject(new Error("Service unavailable"));
render(<SearchAnalyticsPage />);

expect(
await screen.findByText(/failed to load search analytics/i)
).toBeInTheDocument();
expect(screen.getByText("Service unavailable")).toBeInTheDocument();
});

it("renders weekly trends chart sections", async () => {
serviceState.current = () => Promise.resolve(makeAnalytics());
render(<SearchAnalyticsPage />);

await screen.findByText("Search Analytics");
expect(
screen.getByText("Weekly Query Volume")
).toBeInTheDocument();
expect(
screen.getByText("Click-Through Rate Trend")
).toBeInTheDocument();
});
});
101 changes: 101 additions & 0 deletions __tests__/admin/admin-search-analytics.service.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
/**
* Admin search-analytics service — contract tests (#326).
* ------------------------------------------------------------------
* The service in `lib/actions/admin-search-analytics.js` is the seam the
* admin search-analytics page calls for the snapshot. These tests pin the
* resolved shape the UI depends on and, crucially, that metrics without
* backend instrumentation resolve with `tracked: false` so the page renders
* a "not tracked yet" placeholder instead of a silent zero.
*/
import { describe, it, expect } from "vitest";
import { fetchSearchAnalytics } from "@/lib/actions/admin-search-analytics";

describe("fetchSearchAnalytics", () => {
it("resolves a snapshot with the documented shape", async () => {
const data = await fetchSearchAnalytics();
expect(typeof data.generatedAt).toBe("string");
expect(typeof data.dateRange).toBe("object");
expect(typeof data.dateRange.from).toBe("string");
expect(typeof data.dateRange.to).toBe("string");
});

it("exposes all four total metrics with the correct shape", async () => {
const data = await fetchSearchAnalytics();
for (const key of [
"totalQueries",
"uniqueQueries",
"zeroResultQueries",
"avgClickThroughRate",
]) {
expect(data.totals[key]).toBeDefined();
expect(typeof data.totals[key].label).toBe("string");
expect(typeof data.totals[key].tracked).toBe("boolean");
}
expect(typeof data.totals.totalQueries.value).toBe("number");
expect(typeof data.totals.uniqueQueries.value).toBe("number");
expect(typeof data.totals.zeroResultQueries.value).toBe("number");
// avgClickThroughRate may be null when not tracked
expect(
data.totals.avgClickThroughRate.value === null ||
typeof data.totals.avgClickThroughRate.value === "number"
).toBe(true);
});

it("marks all metrics as not tracked when backend is stubbed", async () => {
const data = await fetchSearchAnalytics();
expect(data.totals.totalQueries.tracked).toBe(false);
expect(data.totals.uniqueQueries.tracked).toBe(false);
expect(data.totals.zeroResultQueries.tracked).toBe(false);
expect(data.totals.avgClickThroughRate.tracked).toBe(false);
});

it("provides an empty top queries list", async () => {
const data = await fetchSearchAnalytics();
expect(Array.isArray(data.topQueries)).toBe(true);
expect(data.topQueries.length).toBe(0);
});

it("provides an empty zero-result queries list", async () => {
const data = await fetchSearchAnalytics();
expect(Array.isArray(data.zeroResultQueries)).toBe(true);
expect(data.zeroResultQueries.length).toBe(0);
});

it("provides weekly trends with the correct shape", async () => {
const data = await fetchSearchAnalytics();
expect(Array.isArray(data.weeklyTrends)).toBe(true);
expect(data.weeklyTrends.length).toBeGreaterThan(0);
for (const week of data.weeklyTrends) {
expect(typeof week.week).toBe("string");
expect(typeof week.queries).toBe("number");
expect(typeof week.zeroResults).toBe("number");
expect(
week.clickThroughRate === null ||
typeof week.clickThroughRate === "number"
).toBe(true);
}
});

it("accepts custom date range parameters", async () => {
const from = "2026-01-01T00:00:00.000Z";
const to = "2026-01-31T23:59:59.999Z";
const data = await fetchSearchAnalytics({ from, to });
expect(data.dateRange.from).toBe(from);
expect(data.dateRange.to).toBe(to);
});

it("falls back to default date range when parameters are omitted", async () => {
const data = await fetchSearchAnalytics();
// The from date should be approximately 30 days ago
const fromDate = new Date(data.dateRange.from);
const toDate = new Date(data.dateRange.to);
const diffDays = Math.round((toDate - fromDate) / (1000 * 60 * 60 * 24));
expect(diffDays).toBeGreaterThanOrEqual(29);
expect(diffDays).toBeLessThanOrEqual(31);
});

it("includes an avgClickThroughRate metric with unit %", async () => {
const data = await fetchSearchAnalytics();
expect(data.totals.avgClickThroughRate.unit).toBe("%");
});
});
Loading
Loading