Skip to content
Merged
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
10 changes: 8 additions & 2 deletions src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useEffect, useState, useCallback, useMemo, useRef, lazy, Suspense } from "react";
import { Routes, Route, NavLink, useNavigate, useLocation } from "react-router-dom";
import { Routes, Route, NavLink, Navigate, useNavigate, useLocation } from "react-router-dom";
import { ThemeToggle } from "./ThemeToggle";
import ServerError from "./components/ServerError";
import useDocumentTitle from "./hooks/useDocumentTitle";
Expand All @@ -24,6 +24,7 @@ import { useSessionExpiry } from "./hooks/useSessionExpiry";
import SessionExpiryBanner from "./components/SessionExpiryBanner";
import WalletStatus from "./components/WalletStatus";
import { useWalletStore } from "./state/walletStore";
import { useToast } from "./components/Toast";

// Route splitting: dynamic imports for all heavy page routes
const MarketplacePage = lazy(() => import("./pages/MarketplacePage"));
Expand Down Expand Up @@ -216,7 +217,8 @@ const APP_ROUTES = {
themePlayground: "/theme-playground",
designSystem: "/design-system/docs",
serverError: "/500",
rateLimitCard: "/rate-limit",
rateLimitCard: "/marketplace/:apiId/rate-limits",
legacyRateLimit: "/rate-limit",
slaCard: "/marketplace/:apiId/sla",
webhookDeliveries: "/webhooks/deliveries",
onboarding: "/onboarding",
Expand All @@ -235,7 +237,11 @@ const PRIMARY_NAV: Array<{ to: string; label: string }> = [
/** `/rate-limit` no longer exists; limits are per-API. Send users to the Marketplace. */
function LegacyRateLimitRedirect() {
const { showToast } = useToast();
const announced = useRef(false);
useEffect(() => {
// Guard against StrictMode's double effect invocation in development.
if (announced.current) return;
announced.current = true;
showToast("Rate limits are now shown per API. Pick an API to see its limits.");
}, [showToast]);
return <Navigate to={APP_ROUTES.marketplace} replace />;
Expand Down
2 changes: 1 addition & 1 deletion src/RouteSplitting.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ describe('Route Splitting and Responsiveness Suite (Quality-2)', () => {
});

it('dynamically loads Rate Limit Card route inside Suspense boundary', async () => {
renderApp('/rate-limit');
renderApp('/marketplace/weather-001/rate-limits');
await waitFor(() => {
expect(screen.getByRole('heading', { name: /Rate Limit Configuration/i })).toBeTruthy();
}, { timeout: 4000 });
Expand Down
9 changes: 9 additions & 0 deletions src/pages/ApiDetailPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1325,6 +1325,15 @@ export default function ApiDetailPage({ onBack }: Props) {
View SLA
</Link>
</div>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", paddingTop: "var(--mkt-space-xs)", borderTop: "1px solid var(--line, rgba(255,255,255,0.06))" }}>
<span style={{ fontSize: 14, color: "var(--muted)" }}>Rate limits</span>
<Link
to={`/marketplace/${api.id}/rate-limits`}
style={{ fontSize: 14, color: "var(--accent)", textDecoration: "none", fontWeight: 500 }}
>
View rate limits
</Link>
</div>
</div>
</div>

Expand Down
255 changes: 98 additions & 157 deletions src/pages/RateLimitCard.test.tsx
Original file line number Diff line number Diff line change
@@ -1,30 +1,48 @@
// @vitest-environment jsdom

import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { MemoryRouter } from "react-router-dom";
import RateLimitCard, { type RateLimitCardProps } from "./RateLimitCard";
import { cleanup, render, screen, within } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { MemoryRouter, Route, Routes } from "react-router-dom";
import RateLimitCard, { PLAN_PATTERNS, type RateLimitCardProps } from "./RateLimitCard";
import type { APIItem } from "../data/mockApis";
import { ROUTE_CATALOGUE } from "../config/routeCatalogue";

/**
* RateLimitCard tests – issue #567 (GrantFox FWC26 "Stellar Wave")
* and mobile responsive stacked plan cards.
* RateLimitCard tests – per-API rate limits at `/marketplace/:apiId/rate-limits`.
*
* Covers:
* 1. Mobile layout: four plan cards, no table
* 2. Desktop layout: table with four body rows
* 3. currentPlan prop: badge and aria-current on card or row
* 4. Numbers: non-breaking tabular-nums span
* 5. Pattern text: exposed to assistive tech
* 6. Style injection: no inline <style> tag rendered by component
* 7. Breadcrumb presence and middle-ellipsis truncation
* Covers loading / ready / not-published / not-found / error states, optional
* columns, current-plan marking, mobile cards, breadcrumb and number format.
*/

import RateLimitCard from "./RateLimitCard";
const FIXTURES: APIItem[] = [
{
id: "three-tier",
name: "Three Tier API",
rateLimits: [
{ plan: "Free", requestsPerMinute: 60, burstLimit: 120, concurrentConnections: 2 },
{ plan: "Pro", requestsPerMinute: 6000, burstLimit: 9000, concurrentConnections: 50 },
{ plan: "Enterprise", requestsPerMinute: 120000, burstLimit: 200000, concurrentConnections: 500 },
],
},
{
id: "no-burst",
name: "No Burst API",
rateLimits: [
{ plan: "Standard", requestsPerMinute: 300 },
{ plan: "Premium", requestsPerMinute: 3000 },
],
},
{ id: "unpublished", name: "Unpublished API" },
] as unknown as APIItem[];

const fetchCatalogMock = vi.fn();

vi.mock("../api/catalogApi", () => ({
fetchCatalog: (...args: unknown[]) => fetchCatalogMock(...args),
isCatalogAbortError: (err: unknown) =>
err instanceof DOMException && err.name === "AbortError",
}));

/**
* Forces `window.matchMedia("(max-width: 639px)")` to report a match so the
* component renders its mobile card layout.
*/
function mockViewport(isMobile: boolean) {
const original = window.matchMedia;
window.matchMedia = vi.fn().mockImplementation((query: string) => ({
Expand All @@ -42,17 +60,29 @@ function mockViewport(isMobile: boolean) {
};
}

function renderPage(props?: RateLimitCardProps) {
function renderPage(apiId: string, props: Omit<RateLimitCardProps, "apiId"> = {}) {
return render(
<MemoryRouter>
<RateLimitCard {...props} />
<MemoryRouter initialEntries={[`/marketplace/${apiId}/rate-limits`]}>
<Routes>
<Route path="/marketplace/:apiId/rate-limits" element={<RateLimitCard {...props} />} />
</Routes>
</MemoryRouter>,
);
}

describe("RateLimitCard", () => {
beforeEach(() => vi.clearAllMocks());
afterEach(() => cleanup());
let restoreViewport: () => void = () => {};

beforeEach(() => {
fetchCatalogMock.mockReset();
fetchCatalogMock.mockResolvedValue(FIXTURES);
restoreViewport = mockViewport(false);
});

afterEach(() => {
cleanup();
restoreViewport();
});

it("shows a loading state first", () => {
renderPage("three-tier");
Expand Down Expand Up @@ -83,162 +113,73 @@ describe("RateLimitCard", () => {
expect(screen.queryByRole("table")).toBeNull();
});

// ── Rate-limit table (Desktop) ────────────────────────────────────────────

it("renders the rate-limit tier table", () => {
renderPage();
expect(screen.getByRole("table", { name: /rate limit tiers/i })).toBeTruthy();
it("shows a not-found panel for an unknown id", async () => {
renderPage("does-not-exist");
const alert = await screen.findByRole("alert");
expect(alert.textContent).toMatch(/API Not Found/);
expect(within(alert).getByRole("link", { name: /Back to Marketplace/ }).getAttribute("href")).toBe("/marketplace");
});

it("renders the table with four body rows on desktop viewports", () => {
const { container } = renderPage();
const table = screen.getByRole("table", { name: /rate limit tiers/i });
expect(table).toBeTruthy();
const rows = container.querySelectorAll("tbody tr");
expect(rows.length).toBe(4);
expect(container.querySelectorAll(".rate-limit-plan-card").length).toBe(0);
});

it("shows all four plan tiers", () => {
renderPage();
for (const plan of ["Free", "Developer", "Pro", "Enterprise"]) {
expect(screen.getByText(plan)).toBeTruthy();
}
expect(nav.textContent).toContain("Three Tier API");
it("shows an error panel when the catalogue fails to load", async () => {
fetchCatalogMock.mockRejectedValue(new Error("boom"));
renderPage("three-tier");
const alert = await screen.findByRole("alert");
expect(alert.textContent).toMatch(/unavailable/i);
});

it("contains no GrantFox literals", async () => {
const { container } = renderPage("three-tier");
it("builds a breadcrumb from the API name whose hrefs all resolve to existing routes", async () => {
renderPage("three-tier");
await screen.findByRole("table");
expect(container.textContent).not.toMatch(/GrantFox/i);
});
});

it("renders the throttle note below the table", () => {
renderPage();
expect(
screen.getByText(/rolling 60-second window/i),
).toBeTruthy();
});

// ── Mobile Plan Cards ─────────────────────────────────────────────────────

it("renders four plan cards and no table on mobile viewports (< 640px)", () => {
const restore = mockViewport(true);
try {
const { container } = renderPage();
expect(screen.queryByRole("table")).toBeNull();
const cards = container.querySelectorAll(".rate-limit-plan-card");
expect(cards.length).toBe(4);
for (const plan of ["Free", "Developer", "Pro", "Enterprise"]) {
expect(screen.getByText(plan)).toBeTruthy();
}
} finally {
restore();
const nav = screen.getByRole("navigation", { name: /breadcrumb/i });
expect(nav.textContent).toContain("Marketplace");
expect(nav.textContent).toContain("Three Tier API");
const hrefs = Array.from(nav.querySelectorAll("a")).map((a) => a.getAttribute("href"));
for (const href of hrefs) {
expect(href).toMatch(/^\/(marketplace|details\/three-tier|marketplace\/three-tier\/rate-limits)$/);
}
});

// ── Current Plan ──────────────────────────────────────────────────────────

it("marks the specified currentPlan row with 'Current plan' badge and aria-current on desktop", () => {
const { container } = renderPage({ currentPlan: "Pro" });
const currentRow = container.querySelector('tbody tr[aria-current="true"]');
expect(currentRow).not.toBeNull();
it("marks the specified currentPlan row with a 'Current plan' badge", async () => {
renderPage("three-tier", { currentPlan: "Pro" });
const table = await screen.findByRole("table");
const currentRow = table.querySelector('tbody tr[aria-current="true"]');
expect(currentRow?.textContent).toContain("Pro");
expect(currentRow?.textContent).toContain("Current plan");

const nonCurrentRows = container.querySelectorAll('tbody tr:not([aria-current="true"])');
expect(nonCurrentRows.length).toBe(3);
for (const row of Array.from(nonCurrentRows)) {
expect(row.textContent).not.toContain("Current plan");
}
expect(table.querySelectorAll('tbody tr:not([aria-current="true"])').length).toBe(2);
});

it("marks the specified currentPlan card with 'Current plan' badge and aria-current on mobile", () => {
const restore = mockViewport(true);
try {
const { container } = renderPage({ currentPlan: "Pro" });
const currentCard = container.querySelector('.rate-limit-plan-card[aria-current="true"]');
expect(currentCard).not.toBeNull();
expect(currentCard?.textContent).toContain("Pro");
expect(currentCard?.textContent).toContain("Current plan");

const nonCurrentCards = container.querySelectorAll('.rate-limit-plan-card:not([aria-current="true"])');
expect(nonCurrentCards.length).toBe(3);
for (const card of Array.from(nonCurrentCards)) {
expect(card.textContent).not.toContain("Current plan");
}
} finally {
restore();
}
it("renders stacked plan cards and no table on mobile viewports", async () => {
restoreViewport();
restoreViewport = mockViewport(true);
const { container } = renderPage("three-tier");
await screen.findByText("Enterprise");
expect(screen.queryByRole("table")).toBeNull();
expect(container.querySelectorAll(".rate-limit-plan-card").length).toBe(3);
});

// ── Numbers ───────────────────────────────────────────────────────────────

it("renders numbers like 6,000 inside a single non-breaking tabular-nums span on desktop", () => {
renderPage();
const span = screen.getByText("6,000");
it("renders numbers inside a tabular-nums span with a pattern badge per plan", async () => {
renderPage("three-tier");
await screen.findByRole("table");
const span = screen.getByText(new Intl.NumberFormat().format(6000));
expect(span.tagName).toBe("SPAN");
expect(span.classList.contains("rate-limit-number")).toBe(true);
});

it("renders numbers like 6,000 inside a single non-breaking tabular-nums span on mobile dl", () => {
const restore = mockViewport(true);
try {
renderPage();
const span = screen.getByText("6,000");
expect(span.tagName).toBe("SPAN");
expect(span.classList.contains("rate-limit-number")).toBe(true);
} finally {
restore();
}
});

// ── Color-blind safe patterns & Pattern text ──────────────────────────────

it.each(["Free", "Developer", "Pro", "Enterprise"])(
`%s badge has a distinct pattern class for color-blind safety`,
(plan) => {
renderPage();
for (const plan of ["Free", "Pro", "Enterprise"]) {
const badge = screen.getByText(plan);
const expectedClass = PLAN_PATTERN_CLASSES[plan];
expect(badge.classList.contains("rate-limit-badge")).toBe(true);
expect(badge.classList.contains(expectedClass)).toBe(true);
},
);

it("each plan badge carries a data-pattern attribute describing the texture", () => {
renderPage();
for (const plan of ["Free", "Developer", "Pro", "Enterprise"]) {
const badge = screen.getByText(plan);
expect(badge.classList.contains(PLAN_PATTERNS[plan].class)).toBe(true);
expect(badge.getAttribute("data-pattern")).toBeTruthy();
}
});

it("each plan exposes its pattern description to assistive tech on desktop", () => {
renderPage();
for (const desc of ["solid baseline", "dot pattern", "diagonal stripes", "crosshatch pattern"]) {
expect(screen.getByText(desc)).toBeTruthy();
}
});

it("each plan exposes its pattern description to assistive tech on mobile", () => {
const restore = mockViewport(true);
try {
renderPage();
for (const desc of ["solid baseline", "dot pattern", "diagonal stripes", "crosshatch pattern"]) {
expect(screen.getByText(desc)).toBeTruthy();
}
} finally {
restore();
}
it("contains no GrantFox literals", async () => {
const { container } = renderPage("three-tier");
await screen.findByRole("table");
expect(container.textContent).not.toMatch(/GrantFox/i);
});
});

// ── Style injection ───────────────────────────────────────────────────────

it("renders no inline <style> element directly within the rate-limit page", () => {
const { container } = renderPage();
const pageStyle = container.querySelector(".rate-limit-page > style");
expect(pageStyle).toBeNull();
describe("ROUTE_CATALOGUE", () => {
it("has no dead /rate-limit entry", () => {
expect(ROUTE_CATALOGUE.some((entry) => entry.path === "/rate-limit")).toBe(false);
});
});
Loading
Loading