From 82b97b0fb084495fd4bcc631585a2f5ead7dd651 Mon Sep 17 00:00:00 2001 From: Codebmk Date: Sun, 28 Jun 2026 15:12:06 +0300 Subject: [PATCH 01/21] Add tests and extract isRouteAccessible helper Add comprehensive unit tests for proxyConfig, vertex-config, useContextAwareRouting, api-routing, and utils to improve coverage and validate edge cases. Refactor useContextAwareRouting by extracting isRouteAccessible into a standalone exported helper and update the hook to call it with the sidebar config, making routing logic easier to test and reuse. Tests include environment mocking and logger stubs for validateProxyConfig and various cases for API URL resolution, path versioning, class name utilities, and elapsed duration calculations. --- src/vertex/core/config/proxyConfig.test.ts | 96 +++++++++++++++++++ src/vertex/core/config/vertex-config.test.ts | 82 ++++++++++++++++ .../core/hooks/useContextAwareRouting.test.ts | 39 ++++++++ .../core/hooks/useContextAwareRouting.ts | 38 ++++---- src/vertex/lib/api-routing.test.ts | 91 ++++++++++++++++++ src/vertex/lib/utils.test.ts | 55 +++++++++++ 6 files changed, 382 insertions(+), 19 deletions(-) create mode 100644 src/vertex/core/config/proxyConfig.test.ts create mode 100644 src/vertex/core/config/vertex-config.test.ts create mode 100644 src/vertex/core/hooks/useContextAwareRouting.test.ts create mode 100644 src/vertex/lib/api-routing.test.ts create mode 100644 src/vertex/lib/utils.test.ts diff --git a/src/vertex/core/config/proxyConfig.test.ts b/src/vertex/core/config/proxyConfig.test.ts new file mode 100644 index 0000000000..991c13c721 --- /dev/null +++ b/src/vertex/core/config/proxyConfig.test.ts @@ -0,0 +1,96 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { + getEndpointConfig, + getAuthOptions, + validateProxyConfig, + AUTH_TYPES, + DEFAULT_ENDPOINT_CONFIG, +} from "./proxyConfig"; +import logger from "@/lib/logger"; + +vi.mock("@/lib/logger", () => ({ + default: { + error: vi.fn(), + }, +})); + +describe("proxyConfig", () => { + describe("getEndpointConfig", () => { + const endpointCases = [ + { name: "known endpoint (users)", endpoint: "users", expectedAuth: true, expectedToken: false }, + { name: "known endpoint (data)", endpoint: "data", expectedAuth: false, expectedToken: true }, + { name: "known endpoint mixed (user-devices)", endpoint: "user-devices", expectedAuth: true, expectedToken: true }, + { name: "known endpoint public (health)", endpoint: "health", expectedAuth: false, expectedToken: false }, + { name: "unknown endpoint", endpoint: "unknown", expectedAuth: DEFAULT_ENDPOINT_CONFIG.requiresAuth, expectedToken: DEFAULT_ENDPOINT_CONFIG.requiresApiToken }, + { name: "case insensitive", endpoint: "USERS", expectedAuth: true, expectedToken: false }, + ]; + + it.each(endpointCases)("returns correct config for $name", ({ endpoint, expectedAuth, expectedToken }) => { + const config = getEndpointConfig(endpoint); + expect(config.requiresAuth).toBe(expectedAuth); + expect(config.requiresApiToken).toBe(expectedToken); + }); + }); + + describe("getAuthOptions", () => { + const authCases = [ + { name: "explicit NONE", path: ["users"], header: AUTH_TYPES.NONE, expectedAuth: false, expectedToken: false }, + { name: "explicit JWT", path: ["data"], header: AUTH_TYPES.JWT, expectedAuth: true, expectedToken: false }, + { name: "explicit API_TOKEN", path: ["users"], header: AUTH_TYPES.API_TOKEN, expectedAuth: false, expectedToken: true }, + { name: "explicit AUTO fallback to endpoint", path: ["users"], header: AUTH_TYPES.AUTO, expectedAuth: true, expectedToken: false }, + { name: "no header fallback to endpoint (array path)", path: ["data", "123"], header: undefined, expectedAuth: false, expectedToken: true }, + { name: "no header fallback to endpoint (string path)", path: "measurements/recent", header: undefined, expectedAuth: false, expectedToken: true }, + { name: "unknown endpoint fallback", path: ["unknown"], header: undefined, expectedAuth: DEFAULT_ENDPOINT_CONFIG.requiresAuth, expectedToken: DEFAULT_ENDPOINT_CONFIG.requiresApiToken }, + { name: "empty path fallback", path: [], header: undefined, expectedAuth: DEFAULT_ENDPOINT_CONFIG.requiresAuth, expectedToken: DEFAULT_ENDPOINT_CONFIG.requiresApiToken }, + { name: "empty string path fallback", path: "", header: undefined, expectedAuth: DEFAULT_ENDPOINT_CONFIG.requiresAuth, expectedToken: DEFAULT_ENDPOINT_CONFIG.requiresApiToken }, + ]; + + it.each(authCases)("resolves auth for $name", ({ path, header, expectedAuth, expectedToken }) => { + const config = getAuthOptions(path, header); + expect(config.requiresAuth).toBe(expectedAuth); + expect(config.requiresApiToken).toBe(expectedToken); + }); + }); + + describe("validateProxyConfig", () => { + let originalEnv: NodeJS.ProcessEnv; + + beforeEach(() => { + originalEnv = process.env; + process.env = { ...originalEnv }; + vi.clearAllMocks(); + }); + + afterEach(() => { + process.env = originalEnv; + }); + + it("returns false and logs error if NEXT_PUBLIC_API_URL is missing", () => { + delete process.env.NEXT_PUBLIC_API_URL; + process.env.NEXT_PUBLIC_API_TOKEN = "token"; + expect(validateProxyConfig()).toBe(false); + expect(logger.error).toHaveBeenCalledWith("NEXT_PUBLIC_API_URL is not set in environment variables"); + }); + + it("returns false and logs error if NEXT_PUBLIC_API_URL is invalid", () => { + process.env.NEXT_PUBLIC_API_URL = "not-a-url"; + process.env.NEXT_PUBLIC_API_TOKEN = "token"; + expect(validateProxyConfig()).toBe(false); + expect(logger.error).toHaveBeenCalledWith("NEXT_PUBLIC_API_URL is not a valid URL", { url: "not-a-url" }); + }); + + it("returns false and logs error if NEXT_PUBLIC_API_TOKEN is missing", () => { + process.env.NEXT_PUBLIC_API_URL = "https://api.airqo.net"; + delete process.env.NEXT_PUBLIC_API_TOKEN; + expect(validateProxyConfig()).toBe(false); + expect(logger.error).toHaveBeenCalledWith("Missing required environment variables for proxy:", { missing: ["NEXT_PUBLIC_API_TOKEN"] }); + }); + + it("returns true when configuration is valid", () => { + process.env.NEXT_PUBLIC_API_URL = "https://api.airqo.net"; + process.env.NEXT_PUBLIC_API_TOKEN = "valid-token"; + expect(validateProxyConfig()).toBe(true); + expect(logger.error).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/src/vertex/core/config/vertex-config.test.ts b/src/vertex/core/config/vertex-config.test.ts new file mode 100644 index 0000000000..85be52893c --- /dev/null +++ b/src/vertex/core/config/vertex-config.test.ts @@ -0,0 +1,82 @@ +import { describe, it, expect } from "vitest"; +import { validateVertexConfig, defaultVertexConfig, VertexConfigInput } from "./vertex-config"; + +describe("vertex-config", () => { + describe("validateVertexConfig", () => { + it("validates the default configuration successfully", () => { + expect(() => validateVertexConfig(defaultVertexConfig)).not.toThrow(); + const result = validateVertexConfig(defaultVertexConfig); + expect(result.org.name).toBe("Vertex Demo"); + }); + + const validationCases = [ + { + name: "fails on missing org name", + modifier: (config: VertexConfigInput) => { config.org.name = ""; }, + expectedError: /org.name: Organization name is required/, + }, + { + name: "fails on invalid org slug", + modifier: (config: VertexConfigInput) => { config.org.slug = "Invalid_Slug!"; }, + expectedError: /org.slug: Organization slug must use lowercase letters, numbers, and hyphens/, + }, + { + name: "fails on invalid hex color", + modifier: (config: VertexConfigInput) => { config.org.primaryColor = "red"; }, + expectedError: /org.primaryColor: Primary color must be a valid hex color/, + }, + { + name: "fails on invalid support email", + modifier: (config: VertexConfigInput) => { config.org.supportEmail = "not-an-email"; }, + expectedError: /org.supportEmail: Support email must be valid/, + }, + { + name: "fails when airqo adapter is used without baseUrl", + modifier: (config: VertexConfigInput) => { + config.api.adapter = "airqo"; + config.api.baseUrl = ""; + }, + expectedError: /api.baseUrl: api.baseUrl is required when api.adapter is airqo/, + }, + { + name: "fails when airqo auth is used without airqo adapter", + modifier: (config: VertexConfigInput) => { + config.auth.provider = "airqo"; + config.api.adapter = "mock"; + }, + expectedError: /auth.provider: auth.provider airqo requires api.adapter airqo/, + }, + { + name: "succeeds with airqo adapter and auth when baseUrl is provided", + modifier: (config: VertexConfigInput) => { + config.api.adapter = "airqo"; + config.api.baseUrl = "https://api.airqo.net"; + config.auth.provider = "airqo"; + }, + expectedError: null, + }, + { + name: "fails on invalid map latitude", + modifier: (config: VertexConfigInput) => { config.map.defaultCenter = [100, 0]; }, + expectedError: /map.defaultCenter: Map latitude must be between -90 and 90/, + }, + { + name: "fails on invalid map zoom", + modifier: (config: VertexConfigInput) => { config.map.defaultZoom = 25; }, + expectedError: /map.defaultZoom: Number must be less than or equal to 22/, + }, + ]; + + it.each(validationCases)("validation: $name", ({ modifier, expectedError }) => { + // Deep clone default config + const testConfig: VertexConfigInput = JSON.parse(JSON.stringify(defaultVertexConfig)); + modifier(testConfig); + + if (expectedError) { + expect(() => validateVertexConfig(testConfig)).toThrow(expectedError); + } else { + expect(() => validateVertexConfig(testConfig)).not.toThrow(); + } + }); + }); +}); diff --git a/src/vertex/core/hooks/useContextAwareRouting.test.ts b/src/vertex/core/hooks/useContextAwareRouting.test.ts new file mode 100644 index 0000000000..67283d1289 --- /dev/null +++ b/src/vertex/core/hooks/useContextAwareRouting.test.ts @@ -0,0 +1,39 @@ +import { describe, it, expect } from "vitest"; +import { isRouteAccessible } from "./useContextAwareRouting"; +import { ROUTE_LINKS } from "@/core/routes"; +import { SidebarConfig } from "./useUserContext"; + +describe("useContextAwareRouting helpers", () => { + describe("isRouteAccessible", () => { + const mockConfig: SidebarConfig = { + title: 'Mock Title', + showNetworkMap: true, + showMyDevices: false, + showDeviceOverview: true, + showSites: false, + showGrids: true, + showCohorts: false, + showUserManagement: true, + showAccessControl: false, + showClaimDevice: true, + showDeployDevice: true, + showNetworks: false, + showShipping: false, + }; + + const routingCases = [ + { name: "home is always accessible", route: ROUTE_LINKS.HOME, expected: true }, + { name: "accessible explicitly (device overview)", route: ROUTE_LINKS.ORG_ASSETS, expected: true }, + { name: "inaccessible explicitly (sites)", route: ROUTE_LINKS.SITES, expected: false }, + { name: "accessible explicitly via user management string", route: "/user-management", expected: true }, + { name: "inaccessible explicitly via access control string", route: "/access-control", expected: false }, + { name: "accessible base path match", route: "/user-management/123/edit", expected: true }, + { name: "inaccessible base path match", route: "/access-control/roles/1", expected: false }, + { name: "defaults to true for unknown paths", route: "/unknown-path", expected: true }, + ]; + + it.each(routingCases)("evaluates accessibility for $name", ({ route, expected }) => { + expect(isRouteAccessible(route, mockConfig)).toBe(expected); + }); + }); +}); diff --git a/src/vertex/core/hooks/useContextAwareRouting.ts b/src/vertex/core/hooks/useContextAwareRouting.ts index 33ced6c4f9..d309e2dc62 100644 --- a/src/vertex/core/hooks/useContextAwareRouting.ts +++ b/src/vertex/core/hooks/useContextAwareRouting.ts @@ -18,6 +18,24 @@ const routeToSidebarConfig: Record = { [ROUTE_LINKS.ADMIN_SHIPPING]: 'showShipping', }; +export const isRouteAccessible = (route: string, sidebarConfig: SidebarConfig): boolean => { + if (route === ROUTE_LINKS.HOME) return true; + + const configKey = routeToSidebarConfig[route]; + if (configKey) { + return sidebarConfig[configKey] === true; + } + + const basePath = route.split('/')[1]; + const baseRoute = `/${basePath}`; + const baseConfigKey = routeToSidebarConfig[baseRoute]; + if (baseConfigKey) { + return sidebarConfig[baseConfigKey] === true; + } + + return true; +}; + export const useContextAwareRouting = () => { const router = useRouter(); const pathname = usePathname(); @@ -33,29 +51,11 @@ export const useContextAwareRouting = () => { const sidebarConfig = getSidebarConfig(); - const isRouteAccessible = (route: string): boolean => { - if (route === ROUTE_LINKS.HOME) return true; - - const configKey = routeToSidebarConfig[route]; - if (configKey) { - return sidebarConfig[configKey] === true; - } - - const basePath = route.split('/')[1]; - const baseRoute = `/${basePath}`; - const baseConfigKey = routeToSidebarConfig[baseRoute]; - if (baseConfigKey) { - return sidebarConfig[baseConfigKey] === true; - } - - return true; - }; - initializedRef.current = true; previousContextRef.current = userContext; // Enforce access on every pathname or context change - if (!isRouteAccessible(pathname)) { + if (!isRouteAccessible(pathname, sidebarConfig)) { router.replace(ROUTE_LINKS.HOME); return; } diff --git a/src/vertex/lib/api-routing.test.ts b/src/vertex/lib/api-routing.test.ts new file mode 100644 index 0000000000..c8f26dd1b6 --- /dev/null +++ b/src/vertex/lib/api-routing.test.ts @@ -0,0 +1,91 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { + resolveApiOrigin, + resolveVersionedApiPath, + buildServerApiUrl, + buildBrowserApiUrl, +} from "./api-routing"; + +describe("api-routing", () => { + let originalEnv: NodeJS.ProcessEnv; + + beforeEach(() => { + originalEnv = process.env; + process.env = { ...originalEnv }; + }); + + afterEach(() => { + process.env = originalEnv; + vi.restoreAllMocks(); + }); + + describe("resolveApiOrigin", () => { + it("throws error if no API URL environment variables are set", () => { + delete process.env.API_BASE_URL; + delete process.env.NEXT_PUBLIC_API_BASE_URL; + delete process.env.NEXT_PUBLIC_API_URL; + delete process.env.NEXT_PUBLIC_BASE_URL; + + expect(() => resolveApiOrigin()).toThrow(/API base URL is not defined/); + }); + + const envCases = [ + { name: "API_BASE_URL", env: { API_BASE_URL: "https://api.airqo.net" }, expected: "https://api.airqo.net" }, + { name: "NEXT_PUBLIC_API_BASE_URL", env: { NEXT_PUBLIC_API_BASE_URL: "https://api.airqo.net/api/v1/" }, expected: "https://api.airqo.net" }, + { name: "NEXT_PUBLIC_API_URL", env: { NEXT_PUBLIC_API_URL: "https://api.airqo.net/api" }, expected: "https://api.airqo.net" }, + { name: "NEXT_PUBLIC_BASE_URL", env: { NEXT_PUBLIC_BASE_URL: "https://api.airqo.net/" }, expected: "https://api.airqo.net" }, + { name: "prioritizes API_BASE_URL", env: { API_BASE_URL: "https://server.api", NEXT_PUBLIC_API_URL: "https://client.api" }, expected: "https://server.api" }, + ]; + + it.each(envCases)("resolves from $name with correct stripping", ({ env, expected }) => { + Object.assign(process.env, env); + expect(resolveApiOrigin()).toBe(expected); + }); + }); + + describe("resolveVersionedApiPath", () => { + const pathCases = [ + { name: "empty path", input: "", expected: "/api/v2" }, + { name: "whitespace path", input: " ", expected: "/api/v2" }, + { name: "absolute URL", input: "https://external.api/data", expected: "https://external.api/data" }, + { name: "already versioned full path", input: "/api/v1/devices", expected: "/api/v1/devices" }, + { name: "already versioned full path without leading slash", input: "api/v1/devices", expected: "/api/v1/devices" }, + { name: "version prefix without api", input: "/v1/devices", expected: "/api/v1/devices" }, + { name: "version prefix without api and slash", input: "v1/devices", expected: "/api/v1/devices" }, + { name: "unversioned path", input: "/devices", expected: "/api/v2/devices" }, + { name: "unversioned path without slash", input: "devices", expected: "/api/v2/devices" }, + { name: "path with query string", input: "/devices?limit=10", expected: "/api/v2/devices?limit=10" }, + { name: "already versioned with query", input: "/api/v1/devices?limit=10", expected: "/api/v1/devices?limit=10" }, + ]; + + it.each(pathCases)("resolves $name", ({ input, expected }) => { + expect(resolveVersionedApiPath(input)).toBe(expected); + }); + }); + + describe("buildServerApiUrl", () => { + beforeEach(() => { + process.env.API_BASE_URL = "https://server.api"; + }); + + const serverUrlCases = [ + { name: "unversioned path", input: "/devices", expected: "https://server.api/api/v2/devices" }, + { name: "absolute path", input: "https://external.api/data", expected: "https://external.api/data" }, + ]; + + it.each(serverUrlCases)("builds $name", ({ input, expected }) => { + expect(buildServerApiUrl(input)).toBe(expected); + }); + }); + + describe("buildBrowserApiUrl", () => { + const browserUrlCases = [ + { name: "unversioned path", input: "/devices", expected: "/api/v2/devices" }, + { name: "absolute path", input: "https://external.api/data", expected: "https://external.api/data" }, + ]; + + it.each(browserUrlCases)("builds $name", ({ input, expected }) => { + expect(buildBrowserApiUrl(input)).toBe(expected); + }); + }); +}); diff --git a/src/vertex/lib/utils.test.ts b/src/vertex/lib/utils.test.ts new file mode 100644 index 0000000000..82508a3bc5 --- /dev/null +++ b/src/vertex/lib/utils.test.ts @@ -0,0 +1,55 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { cn, stripTrailingSlash, getElapsedDurationMapper } from "./utils"; + +describe("utils", () => { + describe("cn", () => { + const cnCases = [ + { name: "merges classes", input: ["bg-red-500", "text-white"], expected: "bg-red-500 text-white" }, + { name: "handles conditional classes", input: ["bg-red-500", false && "text-white", true && "font-bold"], expected: "bg-red-500 font-bold" }, + { name: "resolves tailwind conflicts", input: ["px-2 py-1", "p-4"], expected: "p-4" }, + ]; + + it.each(cnCases)("cn: $name", ({ input, expected }) => { + expect(cn(...input)).toBe(expected); + }); + }); + + describe("stripTrailingSlash", () => { + const slashCases = [ + { name: "no trailing slash", input: "http://example.com", expected: "http://example.com" }, + { name: "one trailing slash", input: "http://example.com/", expected: "http://example.com" }, + { name: "multiple trailing slashes", input: "http://example.com///", expected: "http://example.com" }, + { name: "path with slashes", input: "/api/v1/", expected: "/api/v1" }, + { name: "only slashes", input: "///", expected: "" }, + ]; + + it.each(slashCases)("stripTrailingSlash: $name", ({ input, expected }) => { + expect(stripTrailingSlash(input)).toBe(expected); + }); + }); + + describe("getElapsedDurationMapper", () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2024-01-01T12:00:00Z")); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + const durationCases = [ + { name: "same time", input: "2024-01-01T12:00:00Z", expectedSeconds: 0, expectedResult: { year: 0, month: 0, week: 0, day: 0, hour: 0, minute: 0, second: 0 } }, + { name: "one minute ago", input: "2024-01-01T11:59:00Z", expectedSeconds: 60, expectedResult: { year: 0, month: 0, week: 0, day: 0, hour: 0, minute: 1, second: 0 } }, + { name: "one hour and 5 seconds ago", input: "2024-01-01T10:59:55Z", expectedSeconds: 3605, expectedResult: { year: 0, month: 0, week: 0, day: 0, hour: 1, minute: 0, second: 5 } }, + { name: "one day ago", input: "2023-12-31T12:00:00Z", expectedSeconds: 86400, expectedResult: { year: 0, month: 0, week: 0, day: 1, hour: 0, minute: 0, second: 0 } }, + { name: "future date (absolute value)", input: "2024-01-01T12:01:00Z", expectedSeconds: 60, expectedResult: { year: 0, month: 0, week: 0, day: 0, hour: 0, minute: 1, second: 0 } }, + ]; + + it.each(durationCases)("getElapsedDurationMapper: $name", ({ input, expectedSeconds, expectedResult }) => { + const [seconds, result] = getElapsedDurationMapper(input); + expect(seconds).toBe(expectedSeconds); + expect(result).toEqual(expectedResult); + }); + }); +}); From 8ce1deffb17fbe470f9201b68bed9c25976500ef Mon Sep 17 00:00:00 2001 From: Codebmk Date: Sun, 28 Jun 2026 15:22:12 +0300 Subject: [PATCH 02/21] Add hook tests and test utilities Add comprehensive unit tests for core hooks (useBannerWithDelay, useClipboard, useCohorts, useDevices, useGroups, usePermissions, useRecentlyVisited, useRoles, useServerSideTableState, useSites, useUserContext, useWindow). Introduce test helpers and mocks: renderWithProviders, renderHookWithProviders, createTestQueryClient, and setupStore (configuring relevant Redux slices). Also add simple test factories (device, organization, user) and various API/adapter/permission/banner mocks to standardize the testing environment and improve coverage for hook behavior. --- .../core/hooks/useBannerWithDelay.test.tsx | 56 ++++++++++++ src/vertex/core/hooks/useClipboard.test.tsx | 55 ++++++++++++ src/vertex/core/hooks/useCohorts.test.tsx | 50 +++++++++++ src/vertex/core/hooks/useDevices.test.tsx | 77 +++++++++++++++++ src/vertex/core/hooks/useGroups.test.tsx | 65 ++++++++++++++ src/vertex/core/hooks/usePermissions.test.tsx | 86 +++++++++++++++++++ .../core/hooks/useRecentlyVisited.test.tsx | 70 +++++++++++++++ src/vertex/core/hooks/useRoles.test.tsx | 49 +++++++++++ .../hooks/useServerSideTableState.test.tsx | 53 ++++++++++++ src/vertex/core/hooks/useSites.test.tsx | 77 +++++++++++++++++ src/vertex/core/hooks/useUserContext.test.tsx | 66 ++++++++++++++ src/vertex/core/hooks/useWindow.test.tsx | 31 +++++++ src/vertex/test/factories/deviceFactory.ts | 6 ++ .../test/factories/organizationFactory.ts | 6 ++ src/vertex/test/factories/userFactory.ts | 7 ++ src/vertex/test/mocks/queryClient.ts | 15 ++++ .../test/utils/renderHookWithProviders.tsx | 34 ++++++++ src/vertex/test/utils/renderWithProviders.tsx | 51 +++++++++++ 18 files changed, 854 insertions(+) create mode 100644 src/vertex/core/hooks/useBannerWithDelay.test.tsx create mode 100644 src/vertex/core/hooks/useClipboard.test.tsx create mode 100644 src/vertex/core/hooks/useCohorts.test.tsx create mode 100644 src/vertex/core/hooks/useDevices.test.tsx create mode 100644 src/vertex/core/hooks/useGroups.test.tsx create mode 100644 src/vertex/core/hooks/usePermissions.test.tsx create mode 100644 src/vertex/core/hooks/useRecentlyVisited.test.tsx create mode 100644 src/vertex/core/hooks/useRoles.test.tsx create mode 100644 src/vertex/core/hooks/useServerSideTableState.test.tsx create mode 100644 src/vertex/core/hooks/useSites.test.tsx create mode 100644 src/vertex/core/hooks/useUserContext.test.tsx create mode 100644 src/vertex/core/hooks/useWindow.test.tsx create mode 100644 src/vertex/test/factories/deviceFactory.ts create mode 100644 src/vertex/test/factories/organizationFactory.ts create mode 100644 src/vertex/test/factories/userFactory.ts create mode 100644 src/vertex/test/mocks/queryClient.ts create mode 100644 src/vertex/test/utils/renderHookWithProviders.tsx create mode 100644 src/vertex/test/utils/renderWithProviders.tsx diff --git a/src/vertex/core/hooks/useBannerWithDelay.test.tsx b/src/vertex/core/hooks/useBannerWithDelay.test.tsx new file mode 100644 index 0000000000..3c0dc7b98d --- /dev/null +++ b/src/vertex/core/hooks/useBannerWithDelay.test.tsx @@ -0,0 +1,56 @@ +import { renderHook, act } from "@testing-library/react"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { useBannerWithDelay } from "./useBannerWithDelay"; +import { useBanner } from "@/context/banner-context"; +import { AFTER_DIALOG_CLOSE_MS } from "@/core/constants/ui"; + +vi.mock("@/context/banner-context", () => ({ + useBanner: vi.fn(), +})); + +describe("useBannerWithDelay", () => { + let showBannerMock: any; + + beforeEach(() => { + showBannerMock = vi.fn(); + (useBanner as any).mockReturnValue({ showBanner: showBannerMock }); + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.clearAllMocks(); + }); + + it("calls showBanner after delay", () => { + const { result } = renderHook(() => useBannerWithDelay()); + + act(() => { + result.current.showBannerWithDelay({ message: "Test", severity: "success" }); + }); + + expect(showBannerMock).not.toHaveBeenCalled(); + + act(() => { + vi.advanceTimersByTime(AFTER_DIALOG_CLOSE_MS); + }); + + expect(showBannerMock).toHaveBeenCalledWith({ message: "Test", severity: "success" }); + }); + + it("clears previous timer on multiple calls", () => { + const { result } = renderHook(() => useBannerWithDelay()); + + act(() => { + result.current.showBannerWithDelay({ message: "First", severity: "info" }); + result.current.showBannerWithDelay({ message: "Second", severity: "info" }); + }); + + act(() => { + vi.advanceTimersByTime(AFTER_DIALOG_CLOSE_MS); + }); + + expect(showBannerMock).toHaveBeenCalledTimes(1); + expect(showBannerMock).toHaveBeenCalledWith({ message: "Second" }); + }); +}); diff --git a/src/vertex/core/hooks/useClipboard.test.tsx b/src/vertex/core/hooks/useClipboard.test.tsx new file mode 100644 index 0000000000..21a188152e --- /dev/null +++ b/src/vertex/core/hooks/useClipboard.test.tsx @@ -0,0 +1,55 @@ +import { renderHook, act } from "@testing-library/react"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { useClipboard } from "./useClipboard"; +import { useBanner } from "@/context/banner-context"; +import { toast } from "@/components/shared/toast/ReusableToast"; + +vi.mock("@/context/banner-context", () => ({ + useBanner: vi.fn(), +})); + +vi.mock("@/components/shared/toast/ReusableToast", () => ({ + toast: { success: vi.fn() }, +})); + +describe("useClipboard", () => { + let showBannerMock: any; + + beforeEach(() => { + showBannerMock = vi.fn(); + (useBanner as any).mockReturnValue({ showBanner: showBannerMock }); + Object.assign(navigator, { + clipboard: { writeText: vi.fn() }, + }); + vi.clearAllMocks(); + }); + + it("copies text and shows success toast", async () => { + (navigator.clipboard.writeText as any).mockResolvedValue(undefined); + const { result } = renderHook(() => useClipboard()); + + await act(async () => { + await result.current.handleCopy("test text"); + }); + + expect(navigator.clipboard.writeText).toHaveBeenCalledWith("test text"); + expect(toast.success).toHaveBeenCalledWith("Copied"); + expect(showBannerMock).not.toHaveBeenCalled(); + }); + + it("shows error banner if copy fails", async () => { + (navigator.clipboard.writeText as any).mockRejectedValue(new Error("fail")); + const { result } = renderHook(() => useClipboard()); + + await act(async () => { + await result.current.handleCopy("test text"); + }); + + expect(showBannerMock).toHaveBeenCalledWith({ + severity: "error", + message: "Failed to copy", + scoped: false, + }); + expect(toast.success).not.toHaveBeenCalled(); + }); +}); diff --git a/src/vertex/core/hooks/useCohorts.test.tsx b/src/vertex/core/hooks/useCohorts.test.tsx new file mode 100644 index 0000000000..5e68d4583f --- /dev/null +++ b/src/vertex/core/hooks/useCohorts.test.tsx @@ -0,0 +1,50 @@ +import { waitFor } from "@testing-library/react"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { useCohorts, useGroupCohorts } from "./useCohorts"; +import { renderHookWithProviders } from "../../test/utils/renderHookWithProviders"; +import { cohorts as cohortsApi } from "../apis/cohorts"; + +vi.mock("../apis/cohorts", () => ({ + cohorts: { + getCohortsSummary: vi.fn(), + getGroupCohorts: vi.fn(), + }, +})); + +describe("useCohorts", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("useCohorts", () => { + it("fetches cohorts summary", async () => { + const mockCohorts = [{ _id: "c1", name: "Cohort 1" }]; + (cohortsApi.getCohortsSummary as any).mockResolvedValue({ cohorts: mockCohorts }); + + const { result } = renderHookWithProviders(() => useCohorts()); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(cohortsApi.getCohortsSummary).toHaveBeenCalled(); + expect(result.current.cohorts).toEqual(mockCohorts); + }); + }); + + describe("useGroupCohorts", () => { + it("fetches group cohorts", async () => { + const mockIds = ["c1", "c2"]; + (cohortsApi.getGroupCohorts as any).mockResolvedValue({ data: mockIds }); + + const { result } = renderHookWithProviders(() => useGroupCohorts("group-1")); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(cohortsApi.getGroupCohorts).toHaveBeenCalledWith("group-1"); + expect(result.current.data).toEqual(mockIds); + }); + }); +}); diff --git a/src/vertex/core/hooks/useDevices.test.tsx b/src/vertex/core/hooks/useDevices.test.tsx new file mode 100644 index 0000000000..c64a052bd3 --- /dev/null +++ b/src/vertex/core/hooks/useDevices.test.tsx @@ -0,0 +1,77 @@ +import { waitFor } from "@testing-library/react"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { useDevices, useDeviceDetails } from "./useDevices"; +import { renderHookWithProviders } from "../../test/utils/renderHookWithProviders"; +import { adapter } from "../adapters"; + +vi.mock("../adapters", () => ({ + adapter: { + getDevices: vi.fn(), + getDevicesByCohorts: vi.fn(), + getDevice: vi.fn(), + }, +})); + +vi.mock("./useCohorts", () => ({ + useGroupCohorts: vi.fn(() => ({ data: ["cohort-1"], isLoading: false })), + usePersonalUserCohorts: vi.fn(() => ({ data: ["cohort-1"], isLoading: false })), +})); + +describe("useDevices", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("useDevices", () => { + it("fetches devices for airqo group", async () => { + const mockDevices = [{ _id: "d1", name: "Device 1" }]; + (adapter.getDevices as any).mockResolvedValue({ devices: mockDevices }); + + const { result } = renderHookWithProviders(() => useDevices(), { + preloadedState: { user: { activeGroup: { grp_title: "airqo" } } }, + }); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(adapter.getDevices).toHaveBeenCalled(); + expect(result.current.devices).toEqual(mockDevices); + }); + + it("fetches devices by cohorts for non-airqo group", async () => { + const mockDevices = [{ _id: "d2", name: "Device 2" }]; + (adapter.getDevicesByCohorts as any).mockResolvedValue({ devices: mockDevices }); + + const { result } = renderHookWithProviders(() => useDevices(), { + preloadedState: { user: { activeGroup: { _id: "org-1", grp_title: "Other Org" } } }, + }); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(adapter.getDevicesByCohorts).toHaveBeenCalledWith( + expect.objectContaining({ cohort_ids: ["cohort-1"] }), + expect.any(AbortSignal) + ); + expect(result.current.devices).toEqual(mockDevices); + }); + }); + + describe("useDeviceDetails", () => { + it("fetches device details", async () => { + const mockDevice = { _id: "d1", name: "Device 1" }; + (adapter.getDevice as any).mockResolvedValue(mockDevice); + + const { result } = renderHookWithProviders(() => useDeviceDetails("d1")); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(adapter.getDevice).toHaveBeenCalledWith("d1"); + expect(result.current.data).toEqual(mockDevice); + }); + }); +}); diff --git a/src/vertex/core/hooks/useGroups.test.tsx b/src/vertex/core/hooks/useGroups.test.tsx new file mode 100644 index 0000000000..597541c979 --- /dev/null +++ b/src/vertex/core/hooks/useGroups.test.tsx @@ -0,0 +1,65 @@ +import { waitFor } from "@testing-library/react"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { useGroups, useGroupsByCohort, useGroupDetails } from "./useGroups"; +import { groupsApi } from "../apis/organizations"; +import { renderHookWithProviders } from "../../test/utils/renderHookWithProviders"; + +vi.mock("../apis/organizations", () => ({ + groupsApi: { + getGroupsApi: vi.fn(), + getGroupsByCohortApi: vi.fn(), + getGroupDetailsApi: vi.fn(), + }, +})); + +describe("useGroups", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("useGroups", () => { + it("fetches and returns groups", async () => { + const mockGroups = [{ _id: "g1", grp_title: "Group 1" }]; + (groupsApi.getGroupsApi as any).mockResolvedValue({ groups: mockGroups }); + + const { result } = renderHookWithProviders(() => useGroups()); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(result.current.groups).toEqual(mockGroups); + expect(result.current.error).toBeNull(); + }); + + it("handles errors", async () => { + const error = new Error("Failed to fetch"); + (groupsApi.getGroupsApi as any).mockRejectedValue(error); + + const { result } = renderHookWithProviders(() => useGroups()); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(result.current.error).toBe(error); + expect(result.current.groups).toEqual([]); + }); + }); + + describe("useGroupsByCohort", () => { + it("fetches groups for a cohort", async () => { + const mockGroups = [{ _id: "g2", grp_title: "Cohort Group" }]; + (groupsApi.getGroupsByCohortApi as any).mockResolvedValue({ groups: mockGroups }); + + const { result } = renderHookWithProviders(() => useGroupsByCohort("cohort-1")); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(groupsApi.getGroupsByCohortApi).toHaveBeenCalledWith("cohort-1"); + expect(result.current.groups).toEqual(mockGroups); + }); + }); +}); diff --git a/src/vertex/core/hooks/usePermissions.test.tsx b/src/vertex/core/hooks/usePermissions.test.tsx new file mode 100644 index 0000000000..53b0676dc3 --- /dev/null +++ b/src/vertex/core/hooks/usePermissions.test.tsx @@ -0,0 +1,86 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { usePermission, useHasAnyPermission } from "./usePermissions"; +import { renderHookWithProviders } from "../../test/utils/renderHookWithProviders"; +import { permissionService } from "@/core/permissions/permissionService"; +import { PERMISSIONS } from "@/core/permissions/constants"; +import { createMockUser } from "../../test/factories/userFactory"; + +vi.mock("@/core/permissions/permissionService", () => ({ + permissionService: { + hasPermission: vi.fn(), + checkPermission: vi.fn(), + getEffectivePermissions: vi.fn(), + getUserRole: vi.fn(), + getUserRoles: vi.fn(), + isSuperAdmin: vi.fn(), + canPerformAction: vi.fn(), + canManageOrganization: vi.fn(), + canViewOrganization: vi.fn(), + getPermissionDescription: vi.fn(), + }, +})); + +describe("usePermissions", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("usePermission", () => { + it("returns false if user is not authenticated", () => { + const { result } = renderHookWithProviders(() => usePermission(PERMISSIONS.DEVICE.VIEW), { + preloadedState: { user: { userDetails: null } }, + }); + expect(result.current).toBe(false); + expect(permissionService.hasPermission).not.toHaveBeenCalled(); + }); + + it("calls permissionService with correct user and returns true", () => { + (permissionService.hasPermission as any).mockReturnValue(true); + const mockUser = createMockUser(); + + const { result } = renderHookWithProviders(() => usePermission(PERMISSIONS.DEVICE.VIEW), { + preloadedState: { + user: { + userDetails: mockUser, + activeGroup: { _id: "org-1" }, + activeNetwork: { _id: "net-1" }, + } + }, + }); + + expect(result.current).toBe(true); + expect(permissionService.hasPermission).toHaveBeenCalledWith( + mockUser, + PERMISSIONS.DEVICE.VIEW, + expect.objectContaining({ + activeOrganization: { _id: "org-1" }, + activeNetwork: { _id: "net-1" } + }) + ); + }); + }); + + describe("useHasAnyPermission", () => { + it("returns true if at least one permission matches", () => { + (permissionService.hasPermission as any).mockImplementation((user: any, perm: string) => { + return perm === PERMISSIONS.SITE.VIEW; + }); + + const { result } = renderHookWithProviders(() => + useHasAnyPermission([PERMISSIONS.DEVICE.VIEW, PERMISSIONS.SITE.VIEW]), + { preloadedState: { user: { userDetails: createMockUser() } } }); + + expect(result.current).toBe(true); + }); + + it("returns false if none match", () => { + (permissionService.hasPermission as any).mockReturnValue(false); + + const { result } = renderHookWithProviders(() => + useHasAnyPermission([PERMISSIONS.DEVICE.VIEW, PERMISSIONS.SITE.VIEW]), + { preloadedState: { user: { userDetails: createMockUser() } } }); + + expect(result.current).toBe(false); + }); + }); +}); diff --git a/src/vertex/core/hooks/useRecentlyVisited.test.tsx b/src/vertex/core/hooks/useRecentlyVisited.test.tsx new file mode 100644 index 0000000000..927aa6933f --- /dev/null +++ b/src/vertex/core/hooks/useRecentlyVisited.test.tsx @@ -0,0 +1,70 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { useRecentlyVisited } from "./useRecentlyVisited"; +import { renderHookWithProviders } from "../../test/utils/renderHookWithProviders"; +import { usePathname } from "next/navigation"; +import { ROUTE_LINKS } from "@/core/routes"; +import { useBanner } from "@/context/banner-context"; + +vi.mock("next/navigation", () => ({ + usePathname: vi.fn(), +})); + +vi.mock("@/context/banner-context", () => ({ + useBanner: vi.fn(), +})); + +describe("useRecentlyVisited", () => { + beforeEach(() => { + (useBanner as any).mockReturnValue({ showBanner: vi.fn() }); + localStorage.clear(); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it("initializes empty if no user is logged in", () => { + (usePathname as any).mockReturnValue(ROUTE_LINKS.HOME); + + const { result } = renderHookWithProviders(() => useRecentlyVisited(), { + preloadedState: { user: { userDetails: null } }, + }); + + expect(result.current.visitedPages).toEqual([]); + }); + + it("adds page to recently visited and saves to localStorage", () => { + (usePathname as any).mockReturnValue("/admin/networks"); + + const { result } = renderHookWithProviders(() => useRecentlyVisited(), { + preloadedState: { user: { userDetails: { _id: "user-1" } } }, + }); + + expect(result.current.visitedPages).toEqual([ + { label: "Sensor Manufacturers", href: "/admin/networks" } + ]); + + const stored = localStorage.getItem("vertex_recently_visited_user-1"); + expect(stored).toContain("Sensor Manufacturers"); + }); + + it("deduplicates identical paths and limits to 4 items", () => { + const state = { preloadedState: { user: { userDetails: { _id: "user-1" } } } }; + + // Seed storage with 4 items + localStorage.setItem("vertex_recently_visited_user-1", JSON.stringify([ + { label: "1", href: "/1" }, + { label: "2", href: "/2" }, + { label: "3", href: "/3" }, + { label: "4", href: "/4" }, + ])); + + (usePathname as any).mockReturnValue("/admin/sites"); // should push out the 4th item + + const { result } = renderHookWithProviders(() => useRecentlyVisited(), state); + + expect(result.current.visitedPages).toHaveLength(4); + expect(result.current.visitedPages[0].label).toBe("Sites"); + expect(result.current.visitedPages[3].label).toBe("3"); // 4 is pushed out + }); +}); diff --git a/src/vertex/core/hooks/useRoles.test.tsx b/src/vertex/core/hooks/useRoles.test.tsx new file mode 100644 index 0000000000..0802d5c1de --- /dev/null +++ b/src/vertex/core/hooks/useRoles.test.tsx @@ -0,0 +1,49 @@ +import { waitFor } from "@testing-library/react"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { useRoles, useGroupRoles } from "./useRoles"; +import { renderHookWithProviders } from "../../test/utils/renderHookWithProviders"; +import { adapter } from "../adapters"; + +vi.mock("../adapters", () => ({ + adapter: { + getRolesApi: vi.fn(), + getOrgRolesApi: vi.fn(), + }, +})); + +describe("useRoles", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("useRoles", () => { + it("fetches and returns roles", async () => { + const mockRoles = [{ _id: "r1", role_name: "Admin" }]; + (adapter.getRolesApi as any).mockResolvedValue({ roles: mockRoles }); + + const { result } = renderHookWithProviders(() => useRoles()); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(result.current.roles).toEqual(mockRoles); + }); + }); + + describe("useGroupRoles", () => { + it("fetches and returns group roles", async () => { + const mockGroupRoles = [{ _id: "gr1", role_name: "Group Admin" }]; + (adapter.getOrgRolesApi as any).mockResolvedValue({ group_roles: mockGroupRoles }); + + const { result } = renderHookWithProviders(() => useGroupRoles("org-1")); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(adapter.getOrgRolesApi).toHaveBeenCalledWith("org-1"); + expect(result.current.grproles).toEqual(mockGroupRoles); + }); + }); +}); diff --git a/src/vertex/core/hooks/useServerSideTableState.test.tsx b/src/vertex/core/hooks/useServerSideTableState.test.tsx new file mode 100644 index 0000000000..d00b4fa114 --- /dev/null +++ b/src/vertex/core/hooks/useServerSideTableState.test.tsx @@ -0,0 +1,53 @@ +import { renderHook, act } from "@testing-library/react"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { useServerSideTableState } from "./useServerSideTableState"; +import { useRouter, usePathname, useSearchParams } from "next/navigation"; + +vi.mock("next/navigation", () => ({ + useRouter: vi.fn(), + usePathname: vi.fn(), + useSearchParams: vi.fn(), +})); + +describe("useServerSideTableState", () => { + let routerReplaceMock: any; + + beforeEach(() => { + routerReplaceMock = vi.fn(); + (useRouter as any).mockReturnValue({ replace: routerReplaceMock }); + (usePathname as any).mockReturnValue("/admin/devices"); + (useSearchParams as any).mockReturnValue(new URLSearchParams("")); + vi.clearAllMocks(); + }); + + it("initializes with default values", () => { + const { result } = renderHook(() => useServerSideTableState({ initialPageSize: 50 })); + + expect(result.current.pagination).toEqual({ pageIndex: 0, pageSize: 50 }); + expect(result.current.searchTerm).toBe(""); + expect(result.current.sorting).toEqual([]); + }); + + it("initializes from URL params", () => { + (useSearchParams as any).mockReturnValue(new URLSearchParams("page=2&limit=10&search=airqo&sortBy=name&order=desc")); + + const { result } = renderHook(() => useServerSideTableState({})); + + expect(result.current.pagination).toEqual({ pageIndex: 1, pageSize: 10 }); + expect(result.current.searchTerm).toBe("airqo"); + expect(result.current.sorting).toEqual([{ id: "name", desc: true }]); + }); + + it("updates router on state change", () => { + const { result } = renderHook(() => useServerSideTableState({})); + + act(() => { + result.current.setSearchTerm("new search"); + }); + + expect(routerReplaceMock).toHaveBeenCalledWith( + "/admin/devices?page=1&limit=25&search=new+search", + { scroll: false } + ); + }); +}); diff --git a/src/vertex/core/hooks/useSites.test.tsx b/src/vertex/core/hooks/useSites.test.tsx new file mode 100644 index 0000000000..fa7a0b49ac --- /dev/null +++ b/src/vertex/core/hooks/useSites.test.tsx @@ -0,0 +1,77 @@ +import { waitFor } from "@testing-library/react"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { useSites, useSiteDetails } from "./useSites"; +import { renderHookWithProviders } from "../../test/utils/renderHookWithProviders"; +import { adapter } from "../adapters"; + +vi.mock("../adapters", () => ({ + adapter: { + getSites: vi.fn(), + getSitesByCohorts: vi.fn(), + getSite: vi.fn(), + }, +})); + +vi.mock("./useCohorts", () => ({ + useGroupCohorts: vi.fn(() => ({ data: ["cohort-1"], isLoading: false })), + usePersonalUserCohorts: vi.fn(() => ({ data: ["cohort-1"], isLoading: false })), +})); + +describe("useSites", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("useSites", () => { + it("fetches sites for airqo group without cohorts", async () => { + const mockSites = [{ _id: "s1", name: "Site 1" }]; + (adapter.getSites as any).mockResolvedValue({ sites: mockSites }); + + const { result } = renderHookWithProviders(() => useSites(), { + preloadedState: { user: { activeGroup: { grp_title: "airqo" } } }, + }); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(adapter.getSites).toHaveBeenCalled(); + expect(result.current.sites).toEqual(mockSites); + }); + + it("fetches sites by cohorts for non-airqo group", async () => { + const mockSites = [{ _id: "s2", name: "Site 2" }]; + (adapter.getSitesByCohorts as any).mockResolvedValue({ sites: mockSites }); + + const { result } = renderHookWithProviders(() => useSites(), { + preloadedState: { user: { activeGroup: { _id: "org-1", grp_title: "Other Org" } } }, + }); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(adapter.getSitesByCohorts).toHaveBeenCalledWith( + expect.objectContaining({ cohort_ids: ["cohort-1"] }), + expect.any(AbortSignal) + ); + expect(result.current.sites).toEqual(mockSites); + }); + }); + + describe("useSiteDetails", () => { + it("fetches site details", async () => { + const mockSite = { _id: "s1", name: "Site 1" }; + (adapter.getSite as any).mockResolvedValue({ data: mockSite }); + + const { result } = renderHookWithProviders(() => useSiteDetails("s1")); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(adapter.getSite).toHaveBeenCalledWith("s1"); + expect(result.current.data).toEqual(mockSite); + }); + }); +}); diff --git a/src/vertex/core/hooks/useUserContext.test.tsx b/src/vertex/core/hooks/useUserContext.test.tsx new file mode 100644 index 0000000000..07b3e6397a --- /dev/null +++ b/src/vertex/core/hooks/useUserContext.test.tsx @@ -0,0 +1,66 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { useUserContext } from "./useUserContext"; +import { renderHookWithProviders } from "../../test/utils/renderHookWithProviders"; +import * as usePermissionsModule from "./usePermissions"; +import { createMockUser } from "../../test/factories/userFactory"; +import { PERMISSIONS } from "@/core/permissions/constants"; + +vi.mock("./usePermissions", () => ({ + usePermission: vi.fn(), +})); + +describe("useUserContext", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("calculates loading states correctly when not initialized", () => { + const { result } = renderHookWithProviders(() => useUserContext(), { + preloadedState: { + user: { isInitialized: false, isAuthenticated: false, userDetails: null } + } + }); + + expect(result.current.isLoading).toBe(true); + expect(result.current.isPermissionsLoading).toBe(false); + }); + + it("identifies personal context and computes scopes", () => { + const { result } = renderHookWithProviders(() => useUserContext(), { + preloadedState: { + user: { + isInitialized: true, + isAuthenticated: true, + userDetails: createMockUser(), + userContext: "personal" + } + } + }); + + expect(result.current.isPersonalContext).toBe(true); + expect(result.current.userScope).toBe("personal"); + expect(result.current.isPersonalScope).toBe(true); + }); + + it("provides sidebar config based on permissions in personal context", () => { + // Mock view sites = true, view devices = false + (usePermissionsModule.usePermission as any).mockImplementation((perm: string) => { + return perm === PERMISSIONS.SITE.VIEW; + }); + + const { result } = renderHookWithProviders(() => useUserContext(), { + preloadedState: { + user: { + isInitialized: true, + isAuthenticated: true, + userDetails: createMockUser(), + userContext: "personal" + } + } + }); + + const sidebar = result.current.getSidebarConfig(); + expect(sidebar.showSites).toBe(true); + expect(sidebar.showDeviceOverview).toBe(false); + }); +}); diff --git a/src/vertex/core/hooks/useWindow.test.tsx b/src/vertex/core/hooks/useWindow.test.tsx new file mode 100644 index 0000000000..c03b1f0842 --- /dev/null +++ b/src/vertex/core/hooks/useWindow.test.tsx @@ -0,0 +1,31 @@ +import { renderHook, act } from "@testing-library/react"; +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { useWindowSize } from "./useWindow"; + +describe("useWindowSize", () => { + beforeEach(() => { + vi.stubGlobal("innerWidth", 1024); + vi.stubGlobal("innerHeight", 768); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("returns initial window dimensions", () => { + const { result } = renderHook(() => useWindowSize()); + expect(result.current).toEqual({ width: 1024, height: 768 }); + }); + + it("updates dimensions on resize", () => { + const { result } = renderHook(() => useWindowSize()); + + act(() => { + vi.stubGlobal("innerWidth", 800); + vi.stubGlobal("innerHeight", 600); + window.dispatchEvent(new Event("resize")); + }); + + expect(result.current).toEqual({ width: 800, height: 600 }); + }); +}); diff --git a/src/vertex/test/factories/deviceFactory.ts b/src/vertex/test/factories/deviceFactory.ts new file mode 100644 index 0000000000..6a793d3d2d --- /dev/null +++ b/src/vertex/test/factories/deviceFactory.ts @@ -0,0 +1,6 @@ +export const createMockDevice = (overrides = {}) => ({ + _id: "device-123", + name: "Test Device", + status: "deployed", + ...overrides, +}); diff --git a/src/vertex/test/factories/organizationFactory.ts b/src/vertex/test/factories/organizationFactory.ts new file mode 100644 index 0000000000..cba70a54ec --- /dev/null +++ b/src/vertex/test/factories/organizationFactory.ts @@ -0,0 +1,6 @@ +export const createMockOrganization = (overrides = {}) => ({ + _id: "org-123", + grp_title: "Test Organization", + grp_description: "A test org", + ...overrides, +}); diff --git a/src/vertex/test/factories/userFactory.ts b/src/vertex/test/factories/userFactory.ts new file mode 100644 index 0000000000..3749d73048 --- /dev/null +++ b/src/vertex/test/factories/userFactory.ts @@ -0,0 +1,7 @@ +export const createMockUser = (overrides = {}) => ({ + _id: "user-123", + email: "test@airqo.net", + firstName: "Test", + lastName: "User", + ...overrides, +}); diff --git a/src/vertex/test/mocks/queryClient.ts b/src/vertex/test/mocks/queryClient.ts new file mode 100644 index 0000000000..6665c2a33c --- /dev/null +++ b/src/vertex/test/mocks/queryClient.ts @@ -0,0 +1,15 @@ +import { QueryClient } from "@tanstack/react-query"; + +export const createTestQueryClient = () => + new QueryClient({ + defaultOptions: { + queries: { + retry: false, + gcTime: 0, + staleTime: 0, + }, + mutations: { + retry: false, + }, + }, + }); diff --git a/src/vertex/test/utils/renderHookWithProviders.tsx b/src/vertex/test/utils/renderHookWithProviders.tsx new file mode 100644 index 0000000000..03fa670611 --- /dev/null +++ b/src/vertex/test/utils/renderHookWithProviders.tsx @@ -0,0 +1,34 @@ +import React from "react"; +import { renderHook, RenderHookOptions } from "@testing-library/react"; +import { Provider } from "react-redux"; +import { QueryClientProvider } from "@tanstack/react-query"; +import { createTestQueryClient } from "../mocks/queryClient"; +import { setupStore } from "./renderWithProviders"; + +interface ExtendedRenderHookOptions extends RenderHookOptions { + preloadedState?: any; +} + +export function renderHookWithProviders( + renderCallback: (initialProps: Props) => Result, + { preloadedState = {}, ...renderOptions }: ExtendedRenderHookOptions = {} +) { + const store = setupStore(preloadedState); + const queryClient = createTestQueryClient(); + + function Wrapper({ children }: { children: React.ReactNode }) { + return ( + + + {children} + + + ); + } + + return { + store, + queryClient, + ...renderHook(renderCallback, { wrapper: Wrapper, ...renderOptions }), + }; +} diff --git a/src/vertex/test/utils/renderWithProviders.tsx b/src/vertex/test/utils/renderWithProviders.tsx new file mode 100644 index 0000000000..d1b72f3b5b --- /dev/null +++ b/src/vertex/test/utils/renderWithProviders.tsx @@ -0,0 +1,51 @@ +import React, { ReactElement } from "react"; +import { render, RenderOptions } from "@testing-library/react"; +import { Provider } from "react-redux"; +import { configureStore } from "@reduxjs/toolkit"; +import { QueryClientProvider } from "@tanstack/react-query"; +import { createTestQueryClient } from "../mocks/queryClient"; +import userReducer from "@/core/redux/slices/userSlice"; +import sitesReducer from "@/core/redux/slices/sitesSlice"; +import devicesReducer from "@/core/redux/slices/devicesSlice"; +import cohortsReducer from "@/core/redux/slices/cohortsSlice"; +import gridsReducer from "@/core/redux/slices/gridsSlice"; +import groupsReducer from "@/core/redux/slices/groupsSlice"; + +interface ExtendedRenderOptions extends Omit { + preloadedState?: any; + route?: string; +} + +export function setupStore(preloadedState?: any) { + return configureStore({ + reducer: { + user: userReducer, + sites: sitesReducer, + devices: devicesReducer, + grids: gridsReducer, + cohorts: cohortsReducer, + groups: groupsReducer, + }, + preloadedState, + }); +} + +export function renderWithProviders( + ui: ReactElement, + { preloadedState = {}, ...renderOptions }: ExtendedRenderOptions = {} +) { + const store = setupStore(preloadedState); + const queryClient = createTestQueryClient(); + + function Wrapper({ children }: { children: React.ReactNode }) { + return ( + + + {children} + + + ); + } + + return { store, queryClient, ...render(ui, { wrapper: Wrapper, ...renderOptions }) }; +} From b69c38a63404a873905fe0737694a3f382a4285a Mon Sep 17 00:00:00 2001 From: Codebmk Date: Sun, 28 Jun 2026 15:25:25 +0300 Subject: [PATCH 03/21] Use combineReducers to create root reducer Replace the inline reducer object with a combined `rootReducer` using Redux Toolkit's `combineReducers`. Adds the `combineReducers` import and constructs a single root reducer from user, sites, devices, grids, cohorts, and groups slices before passing it to `configureStore`. This centralizes reducer composition and makes extending or replacing reducers easier in tests and runtime. --- src/vertex/test/utils/renderWithProviders.tsx | 20 ++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/src/vertex/test/utils/renderWithProviders.tsx b/src/vertex/test/utils/renderWithProviders.tsx index d1b72f3b5b..7141639784 100644 --- a/src/vertex/test/utils/renderWithProviders.tsx +++ b/src/vertex/test/utils/renderWithProviders.tsx @@ -1,7 +1,7 @@ import React, { ReactElement } from "react"; import { render, RenderOptions } from "@testing-library/react"; import { Provider } from "react-redux"; -import { configureStore } from "@reduxjs/toolkit"; +import { configureStore, combineReducers } from "@reduxjs/toolkit"; import { QueryClientProvider } from "@tanstack/react-query"; import { createTestQueryClient } from "../mocks/queryClient"; import userReducer from "@/core/redux/slices/userSlice"; @@ -17,15 +17,17 @@ interface ExtendedRenderOptions extends Omit { } export function setupStore(preloadedState?: any) { + const rootReducer = combineReducers({ + user: userReducer, + sites: sitesReducer, + devices: devicesReducer, + grids: gridsReducer, + cohorts: cohortsReducer, + groups: groupsReducer, + }); + return configureStore({ - reducer: { - user: userReducer, - sites: sitesReducer, - devices: devicesReducer, - grids: gridsReducer, - cohorts: cohortsReducer, - groups: groupsReducer, - }, + reducer: rootReducer, preloadedState, }); } From 7be62caa9a72827e9e7083d219830d0d698420ce Mon Sep 17 00:00:00 2001 From: Codebmk Date: Sun, 28 Jun 2026 15:28:01 +0300 Subject: [PATCH 04/21] Assert info severity in banner test Update useBannerWithDelay test to expect the banner payload to include severity: "info". The assertion in src/vertex/core/hooks/useBannerWithDelay.test.tsx now checks for { message: "Second", severity: "info" }, reflecting the banner payload change to include a default severity. --- src/vertex/core/hooks/useBannerWithDelay.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/vertex/core/hooks/useBannerWithDelay.test.tsx b/src/vertex/core/hooks/useBannerWithDelay.test.tsx index 3c0dc7b98d..523def647d 100644 --- a/src/vertex/core/hooks/useBannerWithDelay.test.tsx +++ b/src/vertex/core/hooks/useBannerWithDelay.test.tsx @@ -51,6 +51,6 @@ describe("useBannerWithDelay", () => { }); expect(showBannerMock).toHaveBeenCalledTimes(1); - expect(showBannerMock).toHaveBeenCalledWith({ message: "Second" }); + expect(showBannerMock).toHaveBeenCalledWith({ message: "Second", severity: "info" }); }); }); From ec983b1a780104aa52b458275f7d2644598fac45 Mon Sep 17 00:00:00 2001 From: Codebmk Date: Sun, 28 Jun 2026 15:56:27 +0300 Subject: [PATCH 05/21] Add tests for shared and UI components Add Vitest + React Testing Library unit tests for multiple shared and UI components. New tests cover shared components (ReusableButton, ReusableFileUpload, ReusableInputField, ReusableSelectInput, ReusableTable, ReusableToast, CardWrapper) and many UI primitives (accordion, alert, avatar, badge, banner, button, calendar, card, checkbox, combobox, command, date-picker, dialog, dropdown-menu, empty-state, form, input, label, popover, progress, select, skeleton, switch, tabs, textarea, tooltip). Tests assert accessibility roles, user interactions, and component states; and include necessary mocks for browser APIs (clipboard, PointerEvent, ResizeObserver, scrollIntoView) and app contexts (next/navigation, banner-context) to stabilize JSDOM test runs. --- .../shared/button/ReusableButton.test.tsx | 28 +++++++++ .../shared/card/CardWrapper.test.tsx | 28 +++++++++ .../fileupload/ReusableFileUpload.test.tsx | 27 +++++++++ .../inputfield/ReusableInputField.test.tsx | 60 +++++++++++++++++++ .../select/ReusableSelectInput.test.tsx | 60 +++++++++++++++++++ .../shared/table/ReusableTable.test.tsx | 35 +++++++++++ .../shared/toast/ReusableToast.test.tsx | 17 ++++++ src/vertex/components/ui/accordion.test.tsx | 25 ++++++++ src/vertex/components/ui/alert.test.tsx | 24 ++++++++ src/vertex/components/ui/avatar.test.tsx | 14 +++++ src/vertex/components/ui/badge.test.tsx | 17 ++++++ src/vertex/components/ui/banner.test.tsx | 20 +++++++ src/vertex/components/ui/button.test.tsx | 34 +++++++++++ src/vertex/components/ui/calendar.test.tsx | 10 ++++ src/vertex/components/ui/card.test.tsx | 23 +++++++ src/vertex/components/ui/checkbox.test.tsx | 17 ++++++ src/vertex/components/ui/combobox.test.tsx | 47 +++++++++++++++ src/vertex/components/ui/command.test.tsx | 30 ++++++++++ src/vertex/components/ui/date-picker.test.tsx | 34 +++++++++++ src/vertex/components/ui/dialog.test.tsx | 56 +++++++++++++++++ .../components/ui/dropdown-menu.test.tsx | 50 ++++++++++++++++ src/vertex/components/ui/empty-state.test.tsx | 21 +++++++ src/vertex/components/ui/form.test.tsx | 35 +++++++++++ src/vertex/components/ui/input.test.tsx | 24 ++++++++ src/vertex/components/ui/label.test.tsx | 12 ++++ src/vertex/components/ui/popover.test.tsx | 38 ++++++++++++ src/vertex/components/ui/progress.test.tsx | 10 ++++ src/vertex/components/ui/select.test.tsx | 58 ++++++++++++++++++ src/vertex/components/ui/skeleton.test.tsx | 15 +++++ src/vertex/components/ui/switch.test.tsx | 17 ++++++ src/vertex/components/ui/tabs.test.tsx | 27 +++++++++ src/vertex/components/ui/textarea.test.tsx | 16 +++++ src/vertex/components/ui/tooltip.test.tsx | 24 ++++++++ 33 files changed, 953 insertions(+) create mode 100644 src/vertex/components/shared/button/ReusableButton.test.tsx create mode 100644 src/vertex/components/shared/card/CardWrapper.test.tsx create mode 100644 src/vertex/components/shared/fileupload/ReusableFileUpload.test.tsx create mode 100644 src/vertex/components/shared/inputfield/ReusableInputField.test.tsx create mode 100644 src/vertex/components/shared/select/ReusableSelectInput.test.tsx create mode 100644 src/vertex/components/shared/table/ReusableTable.test.tsx create mode 100644 src/vertex/components/shared/toast/ReusableToast.test.tsx create mode 100644 src/vertex/components/ui/accordion.test.tsx create mode 100644 src/vertex/components/ui/alert.test.tsx create mode 100644 src/vertex/components/ui/avatar.test.tsx create mode 100644 src/vertex/components/ui/badge.test.tsx create mode 100644 src/vertex/components/ui/banner.test.tsx create mode 100644 src/vertex/components/ui/button.test.tsx create mode 100644 src/vertex/components/ui/calendar.test.tsx create mode 100644 src/vertex/components/ui/card.test.tsx create mode 100644 src/vertex/components/ui/checkbox.test.tsx create mode 100644 src/vertex/components/ui/combobox.test.tsx create mode 100644 src/vertex/components/ui/command.test.tsx create mode 100644 src/vertex/components/ui/date-picker.test.tsx create mode 100644 src/vertex/components/ui/dialog.test.tsx create mode 100644 src/vertex/components/ui/dropdown-menu.test.tsx create mode 100644 src/vertex/components/ui/empty-state.test.tsx create mode 100644 src/vertex/components/ui/form.test.tsx create mode 100644 src/vertex/components/ui/input.test.tsx create mode 100644 src/vertex/components/ui/label.test.tsx create mode 100644 src/vertex/components/ui/popover.test.tsx create mode 100644 src/vertex/components/ui/progress.test.tsx create mode 100644 src/vertex/components/ui/select.test.tsx create mode 100644 src/vertex/components/ui/skeleton.test.tsx create mode 100644 src/vertex/components/ui/switch.test.tsx create mode 100644 src/vertex/components/ui/tabs.test.tsx create mode 100644 src/vertex/components/ui/textarea.test.tsx create mode 100644 src/vertex/components/ui/tooltip.test.tsx diff --git a/src/vertex/components/shared/button/ReusableButton.test.tsx b/src/vertex/components/shared/button/ReusableButton.test.tsx new file mode 100644 index 0000000000..a29a4b26f3 --- /dev/null +++ b/src/vertex/components/shared/button/ReusableButton.test.tsx @@ -0,0 +1,28 @@ +import { render, screen } from "@testing-library/react"; +import { describe, it, expect } from "vitest"; +import ReusableButton from "./ReusableButton"; + +describe("ReusableButton", () => { + it("renders as a button by default", () => { + render(Click me); + expect(screen.getByRole("button", { name: "Click me" })).toBeInTheDocument(); + }); + + it("renders as an anchor when path is provided", () => { + render(Go Home); + expect(screen.getByRole("link", { name: "Go Home" })).toBeInTheDocument(); + }); + + it("handles loading state", () => { + render(Submit); + const button = screen.getByRole("button", { name: "Submit" }); + expect(button).toHaveAttribute("aria-busy", "true"); + expect(button).toBeDisabled(); + }); + + it("renders tooltip span when disabled with permission", () => { + render(Action); + const actionText = screen.getByText("Action"); + expect(actionText).toBeInTheDocument(); + }); +}); diff --git a/src/vertex/components/shared/card/CardWrapper.test.tsx b/src/vertex/components/shared/card/CardWrapper.test.tsx new file mode 100644 index 0000000000..e9507e3a31 --- /dev/null +++ b/src/vertex/components/shared/card/CardWrapper.test.tsx @@ -0,0 +1,28 @@ +import { render, screen } from "@testing-library/react"; +import { describe, it, expect, vi } from "vitest"; +import userEvent from "@testing-library/user-event"; +import Card from "./CardWrapper"; + +describe("CardWrapper", () => { + it("renders with basic content", () => { + render(Card Content); + expect(screen.getByText("Card Content")).toBeInTheDocument(); + }); + + it("renders with headers and footers", () => { + render( + Header} footer={
Footer
}> + Content +
+ ); + expect(screen.getByTestId("header")).toBeInTheDocument(); + expect(screen.getByTestId("footer")).toBeInTheDocument(); + }); + + it("handles clicks", async () => { + const handleClick = vi.fn(); + render(Content); + await userEvent.click(screen.getByTestId("clickable-card")); + expect(handleClick).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/vertex/components/shared/fileupload/ReusableFileUpload.test.tsx b/src/vertex/components/shared/fileupload/ReusableFileUpload.test.tsx new file mode 100644 index 0000000000..9b54d69065 --- /dev/null +++ b/src/vertex/components/shared/fileupload/ReusableFileUpload.test.tsx @@ -0,0 +1,27 @@ +import { render, screen, fireEvent } from "@testing-library/react"; +import { describe, it, expect, vi } from "vitest"; +import ReusableFileUpload from "./ReusableFileUpload"; + +describe("ReusableFileUpload", () => { + it("renders with placeholder and label", () => { + render(); + expect(screen.getByLabelText("Upload File")).toBeInTheDocument(); + expect(screen.getByText("Upload file")).toBeInTheDocument(); // default placeholder + }); + + it("handles file selection", () => { + const handleChange = vi.fn(); + render(); + + const file = new File(["hello"], "hello.png", { type: "image/png" }); + const input = screen.getByLabelText("Upload"); + + fireEvent.change(input, { target: { files: [file] } }); + expect(handleChange).toHaveBeenCalledWith(file); + }); + + it("shows error state", () => { + render(); + expect(screen.getByText("File too large")).toBeInTheDocument(); + }); +}); diff --git a/src/vertex/components/shared/inputfield/ReusableInputField.test.tsx b/src/vertex/components/shared/inputfield/ReusableInputField.test.tsx new file mode 100644 index 0000000000..9a5eca5fb2 --- /dev/null +++ b/src/vertex/components/shared/inputfield/ReusableInputField.test.tsx @@ -0,0 +1,60 @@ +import { render, screen } from "@testing-library/react"; +import { describe, it, expect, vi } from "vitest"; +import userEvent from "@testing-library/user-event"; +import ReusableInputField from "./ReusableInputField"; + +// Mock toast to avoid missing canvas/window functions in jsdom sometimes or to just isolate +vi.mock("../toast/ReusableToast", () => ({ + default: vi.fn(), +})); + +// Mock clipboard +Object.assign(navigator, { + clipboard: { + writeText: vi.fn(), + }, +}); + +describe("ReusableInputField", () => { + it("renders basic input", () => { + render(); + expect(screen.getByLabelText("Email")).toBeInTheDocument(); + expect(screen.getByPlaceholderText("Enter email")).toBeInTheDocument(); + }); + + it("shows error message", () => { + render(); + expect(screen.getByText("Invalid email")).toBeInTheDocument(); + }); + + it("toggles password visibility", async () => { + render(); + const input = screen.getByLabelText("Password"); + expect(input).toHaveAttribute("type", "password"); + + const toggleBtn = screen.getByRole("button", { name: /show password/i }); + await userEvent.click(toggleBtn); + expect(input).toHaveAttribute("type", "text"); + + await userEvent.click(screen.getByRole("button", { name: /hide password/i })); + expect(input).toHaveAttribute("type", "password"); + }); + + it("calls custom action", async () => { + const handleAction = vi.fn(); + const MockIcon = () =>
; + + render( + + ); + + const actionBtn = screen.getByRole("button", { name: "Do search" }); + await userEvent.click(actionBtn); + expect(handleAction).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/vertex/components/shared/select/ReusableSelectInput.test.tsx b/src/vertex/components/shared/select/ReusableSelectInput.test.tsx new file mode 100644 index 0000000000..28a877149e --- /dev/null +++ b/src/vertex/components/shared/select/ReusableSelectInput.test.tsx @@ -0,0 +1,60 @@ +import { render, screen, fireEvent } from "@testing-library/react"; +import { describe, it, expect, vi } from "vitest"; +import userEvent from "@testing-library/user-event"; +import ReusableSelectInput from "./ReusableSelectInput"; + +window.HTMLElement.prototype.scrollIntoView = vi.fn(); + +describe("ReusableSelectInput", () => { + it("renders placeholder correctly", () => { + render( + + + + ); + + const button = screen.getByRole("button"); + expect(button).toBeInTheDocument(); + expect(screen.getByText("Pick fruit")).toBeInTheDocument(); + }); + + it("shows options and handles selection", async () => { + const handleChange = vi.fn(); + render( + + + + + ); + + const button = screen.getByRole("button"); + await userEvent.click(button); + + expect(screen.getByRole("listbox")).toBeInTheDocument(); + const bananaOption = screen.getByText("Banana"); + + await userEvent.click(bananaOption); + + expect(handleChange).toHaveBeenCalledWith( + expect.objectContaining({ + target: expect.objectContaining({ value: "banana" }) + }) + ); + }); + + it("handles keyboard navigation", async () => { + render( + + + + + ); + + const button = screen.getByRole("button"); + button.focus(); + + // Open with arrow down + fireEvent.keyDown(button, { key: "ArrowDown" }); + expect(screen.getByRole("listbox")).toBeInTheDocument(); + }); +}); diff --git a/src/vertex/components/shared/table/ReusableTable.test.tsx b/src/vertex/components/shared/table/ReusableTable.test.tsx new file mode 100644 index 0000000000..f63d46852e --- /dev/null +++ b/src/vertex/components/shared/table/ReusableTable.test.tsx @@ -0,0 +1,35 @@ +import { render, screen } from "@testing-library/react"; +import { describe, it, expect, vi } from "vitest"; +import ReusableTable from "./ReusableTable"; + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ replace: vi.fn() }), + usePathname: () => "/test-path", + useSearchParams: () => new URLSearchParams(), +})); + +vi.mock("@/context/banner-context", () => ({ + useBanner: () => ({ showBanner: vi.fn(), hideBanner: vi.fn() }), +})); + +describe("ReusableTable", () => { + it("renders with empty data", () => { + render(); + expect(screen.getByText("Test Table")).toBeInTheDocument(); + expect(screen.getByText("No data available")).toBeInTheDocument(); + }); + + it("renders data correctly", () => { + const columns = [ + { key: "name", render: (val: any) => val }, + ]; + const data = [ + { id: 1, name: "Alice" }, + { id: 2, name: "Bob" } + ]; + + render(); + expect(screen.getByText("Alice")).toBeInTheDocument(); + expect(screen.getByText("Bob")).toBeInTheDocument(); + }); +}); diff --git a/src/vertex/components/shared/toast/ReusableToast.test.tsx b/src/vertex/components/shared/toast/ReusableToast.test.tsx new file mode 100644 index 0000000000..4c63909df5 --- /dev/null +++ b/src/vertex/components/shared/toast/ReusableToast.test.tsx @@ -0,0 +1,17 @@ +import { render } from "@testing-library/react"; +import { describe, it, expect } from "vitest"; +import { Toaster, toast } from "./ReusableToast"; +import ReusableToast from "./ReusableToast"; + +describe("ReusableToast", () => { + it("can render the Toaster component", () => { + const { container } = render(); + expect(container).toBeInTheDocument(); + }); + + it("exports toast methods", () => { + expect(typeof toast.success).toBe("function"); + expect(typeof toast.error).toBe("function"); + expect(typeof ReusableToast).toBe("function"); + }); +}); diff --git a/src/vertex/components/ui/accordion.test.tsx b/src/vertex/components/ui/accordion.test.tsx new file mode 100644 index 0000000000..049089ae9c --- /dev/null +++ b/src/vertex/components/ui/accordion.test.tsx @@ -0,0 +1,25 @@ +import { render, screen } from "@testing-library/react"; +import { describe, it, expect } from "vitest"; +import userEvent from "@testing-library/user-event"; +import { Accordion, AccordionItem, AccordionTrigger, AccordionContent } from "./accordion"; + +describe("Accordion", () => { + it("expands and collapses", async () => { + render( + + + Open Item 1 + Item 1 Content + + + ); + + // Initial state: content shouldn't be in the document or should be hidden + expect(screen.queryByText("Item 1 Content")).not.toBeInTheDocument(); + + await userEvent.click(screen.getByRole("button", { name: "Open Item 1" })); + + // Radix animates it in, but it should be placed in the DOM right away + expect(screen.getByText("Item 1 Content")).toBeInTheDocument(); + }); +}); diff --git a/src/vertex/components/ui/alert.test.tsx b/src/vertex/components/ui/alert.test.tsx new file mode 100644 index 0000000000..1047fcb7b5 --- /dev/null +++ b/src/vertex/components/ui/alert.test.tsx @@ -0,0 +1,24 @@ +import { render, screen } from "@testing-library/react"; +import { describe, it, expect } from "vitest"; +import { Alert, AlertDescription, AlertTitle } from "./alert"; + +describe("Alert", () => { + it("renders correctly", () => { + render( + + Error! + Something went wrong. + + ); + + expect(screen.getByRole("alert")).toBeInTheDocument(); + expect(screen.getByRole("heading", { level: 5, name: "Error!" })).toBeInTheDocument(); + expect(screen.getByText("Something went wrong.")).toBeInTheDocument(); + }); + + it("applies variant classes", () => { + render(); + const alert = screen.getByTestId("alert-dest"); + expect(alert.className).toContain("destructive"); + }); +}); diff --git a/src/vertex/components/ui/avatar.test.tsx b/src/vertex/components/ui/avatar.test.tsx new file mode 100644 index 0000000000..56dab6c12b --- /dev/null +++ b/src/vertex/components/ui/avatar.test.tsx @@ -0,0 +1,14 @@ +import { render, screen } from "@testing-library/react"; +import { describe, it, expect } from "vitest"; +import { Avatar, AvatarFallback, AvatarImage } from "./avatar"; + +describe("Avatar", () => { + it("renders fallback", () => { + render( + + JD + + ); + expect(screen.getByText("JD")).toBeInTheDocument(); + }); +}); diff --git a/src/vertex/components/ui/badge.test.tsx b/src/vertex/components/ui/badge.test.tsx new file mode 100644 index 0000000000..f9210c002e --- /dev/null +++ b/src/vertex/components/ui/badge.test.tsx @@ -0,0 +1,17 @@ +import { render, screen } from "@testing-library/react"; +import { describe, it, expect } from "vitest"; +import { Badge } from "./badge"; + +describe("Badge", () => { + it("renders with default variant", () => { + render(Default); + const badge = screen.getByText("Default"); + expect(badge).toBeInTheDocument(); + expect(badge.className).toContain("bg-primary"); + }); + + it("renders with destructive variant", () => { + render(Error); + expect(screen.getByText("Error").className).toContain("bg-destructive"); + }); +}); diff --git a/src/vertex/components/ui/banner.test.tsx b/src/vertex/components/ui/banner.test.tsx new file mode 100644 index 0000000000..765c3eb6d3 --- /dev/null +++ b/src/vertex/components/ui/banner.test.tsx @@ -0,0 +1,20 @@ +import { render, screen } from "@testing-library/react"; +import { describe, it, expect, vi } from "vitest"; +import userEvent from "@testing-library/user-event"; +import { Banner, SuccessBanner, ErrorBanner, WarningBanner, InfoBanner } from "./banner"; + +describe("Banner", () => { + it("renders success banner correctly", () => { + render(); + expect(screen.getByText("Success")).toBeInTheDocument(); + expect(screen.getByText("Action was successful")).toBeInTheDocument(); + }); + + it("handles dismiss click", async () => { + const handleDismiss = vi.fn(); + render(); + + await userEvent.click(screen.getByRole("button", { name: "Dismiss" })); + expect(handleDismiss).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/vertex/components/ui/button.test.tsx b/src/vertex/components/ui/button.test.tsx new file mode 100644 index 0000000000..2618264e14 --- /dev/null +++ b/src/vertex/components/ui/button.test.tsx @@ -0,0 +1,34 @@ +import { render, screen } from "@testing-library/react"; +import { describe, it, expect, vi } from "vitest"; +import userEvent from "@testing-library/user-event"; +import { Button } from "./button"; + +describe("Button", () => { + it("renders correctly", () => { + render(); + expect(screen.getByRole("button", { name: "Click me" })).toBeInTheDocument(); + }); + + it("fires onClick handler", async () => { + const handleClick = vi.fn(); + render(); + + await userEvent.click(screen.getByRole("button", { name: "Click me" })); + expect(handleClick).toHaveBeenCalledTimes(1); + }); + + it("is disabled when disabled prop is passed", () => { + render(); + expect(screen.getByRole("button", { name: "Click me" })).toBeDisabled(); + }); + + it("renders as child element when asChild is true", () => { + render( + + ); + expect(screen.getByRole("link", { name: "Link Button" })).toBeInTheDocument(); + expect(screen.queryByRole("button")).not.toBeInTheDocument(); + }); +}); diff --git a/src/vertex/components/ui/calendar.test.tsx b/src/vertex/components/ui/calendar.test.tsx new file mode 100644 index 0000000000..9863a9cbcb --- /dev/null +++ b/src/vertex/components/ui/calendar.test.tsx @@ -0,0 +1,10 @@ +import { render, screen } from "@testing-library/react"; +import { describe, it, expect } from "vitest"; +import { Calendar } from "./calendar"; + +describe("Calendar", () => { + it("renders properly", () => { + render(); + expect(screen.getByText("15")).toBeInTheDocument(); + }); +}); diff --git a/src/vertex/components/ui/card.test.tsx b/src/vertex/components/ui/card.test.tsx new file mode 100644 index 0000000000..cc9cae3bc7 --- /dev/null +++ b/src/vertex/components/ui/card.test.tsx @@ -0,0 +1,23 @@ +import { render, screen } from "@testing-library/react"; +import { describe, it, expect } from "vitest"; +import { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from "./card"; + +describe("Card", () => { + it("renders all card parts", () => { + render( + + + Title + Description + + Content + Footer + + ); + + expect(screen.getByText("Title")).toBeInTheDocument(); + expect(screen.getByText("Description")).toBeInTheDocument(); + expect(screen.getByText("Content")).toBeInTheDocument(); + expect(screen.getByText("Footer")).toBeInTheDocument(); + }); +}); diff --git a/src/vertex/components/ui/checkbox.test.tsx b/src/vertex/components/ui/checkbox.test.tsx new file mode 100644 index 0000000000..18a7f3f506 --- /dev/null +++ b/src/vertex/components/ui/checkbox.test.tsx @@ -0,0 +1,17 @@ +import { render, screen } from "@testing-library/react"; +import { describe, it, expect, vi } from "vitest"; +import userEvent from "@testing-library/user-event"; +import { Checkbox } from "./checkbox"; + +describe("Checkbox", () => { + it("can be checked and unchecked", async () => { + const handleChange = vi.fn(); + render(); + + const checkbox = screen.getByRole("checkbox", { name: "Accept terms" }); + expect(checkbox).toHaveAttribute("aria-checked", "false"); + + await userEvent.click(checkbox); + expect(handleChange).toHaveBeenCalledWith(true); + }); +}); diff --git a/src/vertex/components/ui/combobox.test.tsx b/src/vertex/components/ui/combobox.test.tsx new file mode 100644 index 0000000000..45b43fbdab --- /dev/null +++ b/src/vertex/components/ui/combobox.test.tsx @@ -0,0 +1,47 @@ +import { render, screen } from "@testing-library/react"; +import { describe, it, expect, vi } from "vitest"; +import userEvent from "@testing-library/user-event"; +import { ComboBox } from "./combobox"; + +class MockPointerEvent extends Event { + button: number; + ctrlKey: boolean; + pointerType: string; + + constructor(type: string, props: PointerEventInit) { + super(type, props); + this.button = props.button || 0; + this.ctrlKey = props.ctrlKey || false; + this.pointerType = props.pointerType || "mouse"; + } +} +window.PointerEvent = MockPointerEvent as any; +window.HTMLElement.prototype.scrollIntoView = () => {}; +window.HTMLElement.prototype.hasPointerCapture = () => false; +window.HTMLElement.prototype.releasePointerCapture = () => {}; + +window.ResizeObserver = class ResizeObserver { + observe() {} + unobserve() {} + disconnect() {} +}; + +describe("ComboBox", () => { + it("renders and selects", async () => { + const handleChange = vi.fn(); + const options = [ + { value: "apple", label: "Apple" }, + { value: "banana", label: "Banana" }, + ]; + + render(); + + const button = screen.getByRole("combobox"); + expect(screen.getByText("Select fruit")).toBeInTheDocument(); + + await userEvent.click(button); + await userEvent.click(screen.getByText("Banana")); + + expect(handleChange).toHaveBeenCalledWith("banana"); + }); +}); diff --git a/src/vertex/components/ui/command.test.tsx b/src/vertex/components/ui/command.test.tsx new file mode 100644 index 0000000000..f9172cc395 --- /dev/null +++ b/src/vertex/components/ui/command.test.tsx @@ -0,0 +1,30 @@ +import { render, screen } from "@testing-library/react"; +import { describe, it, expect } from "vitest"; +import { Command, CommandInput, CommandList, CommandItem, CommandEmpty, CommandGroup } from "./command"; + +window.HTMLElement.prototype.scrollIntoView = () => {}; + +window.ResizeObserver = class ResizeObserver { + observe() {} + unobserve() {} + disconnect() {} +}; + +describe("Command", () => { + it("renders structure", () => { + render( + + + + No results found. + + Apple + + + + ); + expect(screen.getByPlaceholderText("Search...")).toBeInTheDocument(); + expect(screen.getByText("Fruits")).toBeInTheDocument(); + expect(screen.getByText("Apple")).toBeInTheDocument(); + }); +}); diff --git a/src/vertex/components/ui/date-picker.test.tsx b/src/vertex/components/ui/date-picker.test.tsx new file mode 100644 index 0000000000..20b0b1e4e1 --- /dev/null +++ b/src/vertex/components/ui/date-picker.test.tsx @@ -0,0 +1,34 @@ +import { render, screen } from "@testing-library/react"; +import { describe, it, expect, vi } from "vitest"; +import userEvent from "@testing-library/user-event"; +import { DatePicker } from "./date-picker"; + +class MockPointerEvent extends Event { + button: number; + ctrlKey: boolean; + pointerType: string; + + constructor(type: string, props: PointerEventInit) { + super(type, props); + this.button = props.button || 0; + this.ctrlKey = props.ctrlKey || false; + this.pointerType = props.pointerType || "mouse"; + } +} +window.PointerEvent = MockPointerEvent as any; +window.HTMLElement.prototype.scrollIntoView = () => {}; +window.HTMLElement.prototype.hasPointerCapture = () => false; +window.HTMLElement.prototype.releasePointerCapture = () => {}; + +describe("DatePicker", () => { + it("opens calendar", async () => { + render(); + const button = screen.getByRole("button", { name: /pick a date/i }); + + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + await userEvent.click(button); + + const dialog = await screen.findByRole("dialog"); + expect(dialog).toBeInTheDocument(); + }); +}); diff --git a/src/vertex/components/ui/dialog.test.tsx b/src/vertex/components/ui/dialog.test.tsx new file mode 100644 index 0000000000..6ded9dc487 --- /dev/null +++ b/src/vertex/components/ui/dialog.test.tsx @@ -0,0 +1,56 @@ +import { render, screen } from "@testing-library/react"; +import { describe, it, expect } from "vitest"; +import userEvent from "@testing-library/user-event"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "./dialog"; + +describe("Dialog", () => { + // Radix UI components use PointerEvents internally, which JSDOM doesn't support well + class MockPointerEvent extends Event { + button: number; + ctrlKey: boolean; + pointerType: string; + + constructor(type: string, props: PointerEventInit) { + super(type, props); + this.button = props.button || 0; + this.ctrlKey = props.ctrlKey || false; + this.pointerType = props.pointerType || "mouse"; + } + } + window.PointerEvent = MockPointerEvent as any; + window.HTMLElement.prototype.scrollIntoView = () => {}; + window.HTMLElement.prototype.hasPointerCapture = () => false; + window.HTMLElement.prototype.releasePointerCapture = () => {}; + + it("opens and closes correctly", async () => { + render( + + Open Dialog + + + Dialog Title + Dialog Description + + + + ); + + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + + await userEvent.click(screen.getByRole("button", { name: "Open Dialog" })); + + const dialog = await screen.findByRole("dialog"); + expect(dialog).toBeInTheDocument(); + expect(screen.getByText("Dialog Title")).toBeInTheDocument(); + expect(screen.getByText("Dialog Description")).toBeInTheDocument(); + + await userEvent.click(screen.getByRole("button", { name: "Close" })); + }); +}); diff --git a/src/vertex/components/ui/dropdown-menu.test.tsx b/src/vertex/components/ui/dropdown-menu.test.tsx new file mode 100644 index 0000000000..5e213168e3 --- /dev/null +++ b/src/vertex/components/ui/dropdown-menu.test.tsx @@ -0,0 +1,50 @@ +import { render, screen } from "@testing-library/react"; +import { describe, it, expect } from "vitest"; +import userEvent from "@testing-library/user-event"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "./dropdown-menu"; + +describe("DropdownMenu", () => { + class MockPointerEvent extends Event { + button: number; + ctrlKey: boolean; + pointerType: string; + + constructor(type: string, props: PointerEventInit) { + super(type, props); + this.button = props.button || 0; + this.ctrlKey = props.ctrlKey || false; + this.pointerType = props.pointerType || "mouse"; + } + } + window.PointerEvent = MockPointerEvent as any; + window.HTMLElement.prototype.scrollIntoView = () => {}; + window.HTMLElement.prototype.hasPointerCapture = () => false; + window.HTMLElement.prototype.releasePointerCapture = () => {}; + + it("opens menu and shows items", async () => { + render( + + Open Menu + + Item 1 + Item 2 + + + ); + + expect(screen.queryByRole("menu")).not.toBeInTheDocument(); + + await userEvent.click(screen.getByRole("button", { name: "Open Menu" })); + + const menu = await screen.findByRole("menu"); + expect(menu).toBeInTheDocument(); + + expect(screen.getByRole("menuitem", { name: "Item 1" })).toBeInTheDocument(); + expect(screen.getByRole("menuitem", { name: "Item 2" })).toBeInTheDocument(); + }); +}); diff --git a/src/vertex/components/ui/empty-state.test.tsx b/src/vertex/components/ui/empty-state.test.tsx new file mode 100644 index 0000000000..455fc13d3d --- /dev/null +++ b/src/vertex/components/ui/empty-state.test.tsx @@ -0,0 +1,21 @@ +import { render, screen } from "@testing-library/react"; +import { describe, it, expect } from "vitest"; +import { EmptyState } from "./empty-state"; + +describe("EmptyState", () => { + it("renders with correct text and action", () => { + render( + } + title="No Data" + description="There is no data to show." + action={} + /> + ); + + expect(screen.getByTestId("test-icon")).toBeInTheDocument(); + expect(screen.getByRole("heading", { level: 3, name: "No Data" })).toBeInTheDocument(); + expect(screen.getByText("There is no data to show.")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Add Data" })).toBeInTheDocument(); + }); +}); diff --git a/src/vertex/components/ui/form.test.tsx b/src/vertex/components/ui/form.test.tsx new file mode 100644 index 0000000000..6b9de2fffa --- /dev/null +++ b/src/vertex/components/ui/form.test.tsx @@ -0,0 +1,35 @@ +import { render, screen } from "@testing-library/react"; +import { describe, it, expect } from "vitest"; +import { useForm } from "react-hook-form"; +import { Form, FormField, FormItem, FormLabel, FormControl, FormDescription, FormMessage } from "./form"; + +const TestForm = () => { + const form = useForm({ defaultValues: { username: "" } }); + return ( +
+ ( + + Username + + + + Your unique handle. + + + )} + /> + + ); +}; + +describe("Form", () => { + it("renders form fields correctly", () => { + render(); + expect(screen.getByText("Username")).toBeInTheDocument(); + expect(screen.getByPlaceholderText("Enter username")).toBeInTheDocument(); + expect(screen.getByText("Your unique handle.")).toBeInTheDocument(); + }); +}); diff --git a/src/vertex/components/ui/input.test.tsx b/src/vertex/components/ui/input.test.tsx new file mode 100644 index 0000000000..6cddc95f38 --- /dev/null +++ b/src/vertex/components/ui/input.test.tsx @@ -0,0 +1,24 @@ +import { render, screen } from "@testing-library/react"; +import { describe, it, expect } from "vitest"; +import userEvent from "@testing-library/user-event"; +import { Input } from "./input"; + +describe("Input", () => { + it("renders correctly", () => { + render(); + expect(screen.getByPlaceholderText("Enter text")).toBeInTheDocument(); + }); + + it("handles user input", async () => { + render(); + const input = screen.getByPlaceholderText("Email"); + + await userEvent.type(input, "test@example.com"); + expect(input).toHaveValue("test@example.com"); + }); + + it("can be disabled", () => { + render(); + expect(screen.getByPlaceholderText("Email")).toBeDisabled(); + }); +}); diff --git a/src/vertex/components/ui/label.test.tsx b/src/vertex/components/ui/label.test.tsx new file mode 100644 index 0000000000..e3fb43b0f5 --- /dev/null +++ b/src/vertex/components/ui/label.test.tsx @@ -0,0 +1,12 @@ +import { render, screen } from "@testing-library/react"; +import { describe, it, expect } from "vitest"; +import { Label } from "./label"; + +describe("Label", () => { + it("renders text content", () => { + render(); + const label = screen.getByText("Test Label"); + expect(label).toBeInTheDocument(); + expect(label).toHaveAttribute("for", "test-input"); + }); +}); diff --git a/src/vertex/components/ui/popover.test.tsx b/src/vertex/components/ui/popover.test.tsx new file mode 100644 index 0000000000..ca9b6b589d --- /dev/null +++ b/src/vertex/components/ui/popover.test.tsx @@ -0,0 +1,38 @@ +import { render, screen } from "@testing-library/react"; +import { describe, it, expect } from "vitest"; +import userEvent from "@testing-library/user-event"; +import { Popover, PopoverContent, PopoverTrigger } from "./popover"; + +class MockPointerEvent extends Event { + button: number; + ctrlKey: boolean; + pointerType: string; + + constructor(type: string, props: PointerEventInit) { + super(type, props); + this.button = props.button || 0; + this.ctrlKey = props.ctrlKey || false; + this.pointerType = props.pointerType || "mouse"; + } +} +window.PointerEvent = MockPointerEvent as any; +window.HTMLElement.prototype.scrollIntoView = () => {}; +window.HTMLElement.prototype.hasPointerCapture = () => false; +window.HTMLElement.prototype.releasePointerCapture = () => {}; + +describe("Popover", () => { + it("shows popover content on click", async () => { + render( + + Open + Popover Text + + ); + + expect(screen.queryByText("Popover Text")).not.toBeInTheDocument(); + + await userEvent.click(screen.getByRole("button", { name: "Open" })); + + expect(await screen.findByText("Popover Text")).toBeInTheDocument(); + }); +}); diff --git a/src/vertex/components/ui/progress.test.tsx b/src/vertex/components/ui/progress.test.tsx new file mode 100644 index 0000000000..2acb7b7ba1 --- /dev/null +++ b/src/vertex/components/ui/progress.test.tsx @@ -0,0 +1,10 @@ +import { render } from "@testing-library/react"; +import { describe, it, expect } from "vitest"; +import { Progress } from "./progress"; + +describe("Progress", () => { + it("renders without crashing", () => { + const { container } = render(); + expect(container).toBeInTheDocument(); + }); +}); diff --git a/src/vertex/components/ui/select.test.tsx b/src/vertex/components/ui/select.test.tsx new file mode 100644 index 0000000000..173e7abd8b --- /dev/null +++ b/src/vertex/components/ui/select.test.tsx @@ -0,0 +1,58 @@ +import { render, screen, within } from "@testing-library/react"; +import { describe, it, expect, vi } from "vitest"; +import userEvent from "@testing-library/user-event"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "./select"; + +describe("Select", () => { + class MockPointerEvent extends Event { + button: number; + ctrlKey: boolean; + pointerType: string; + + constructor(type: string, props: PointerEventInit) { + super(type, props); + this.button = props.button || 0; + this.ctrlKey = props.ctrlKey || false; + this.pointerType = props.pointerType || "mouse"; + } + } + window.PointerEvent = MockPointerEvent as any; + window.HTMLElement.prototype.scrollIntoView = () => {}; + window.HTMLElement.prototype.hasPointerCapture = () => false; + window.HTMLElement.prototype.releasePointerCapture = () => {}; + + it("renders and selects options", async () => { + const handleValueChange = vi.fn(); + + render( + + ); + + const trigger = screen.getByRole("combobox", { name: "Food" }); + expect(trigger).toBeInTheDocument(); + + await userEvent.click(trigger); + + const listbox = await screen.findByRole("listbox"); + expect(listbox).toBeInTheDocument(); + + const appleOption = within(listbox).getByRole("option", { name: "Apple" }); + await userEvent.click(appleOption); + + expect(handleValueChange).toHaveBeenCalledWith("apple"); + }); +}); diff --git a/src/vertex/components/ui/skeleton.test.tsx b/src/vertex/components/ui/skeleton.test.tsx new file mode 100644 index 0000000000..fa72264ed2 --- /dev/null +++ b/src/vertex/components/ui/skeleton.test.tsx @@ -0,0 +1,15 @@ +import { render } from "@testing-library/react"; +import { describe, it, expect } from "vitest"; +import { Skeleton } from "./skeleton"; + +describe("Skeleton", () => { + it("renders correctly", () => { + const { container } = render(); + + const element = container.firstChild as HTMLElement; + expect(element).toBeInTheDocument(); + expect(element.className).toContain("animate-pulse"); + expect(element.className).toContain("w-[100px]"); + expect(element.className).toContain("h-[20px]"); + }); +}); diff --git a/src/vertex/components/ui/switch.test.tsx b/src/vertex/components/ui/switch.test.tsx new file mode 100644 index 0000000000..fce4ce1a62 --- /dev/null +++ b/src/vertex/components/ui/switch.test.tsx @@ -0,0 +1,17 @@ +import { render, screen } from "@testing-library/react"; +import { describe, it, expect, vi } from "vitest"; +import userEvent from "@testing-library/user-event"; +import { Switch } from "./switch"; + +describe("Switch", () => { + it("toggles state", async () => { + const handleChange = vi.fn(); + render(); + + const switchElement = screen.getByRole("switch", { name: "Toggle feature" }); + expect(switchElement).toHaveAttribute("aria-checked", "false"); + + await userEvent.click(switchElement); + expect(handleChange).toHaveBeenCalledWith(true); + }); +}); diff --git a/src/vertex/components/ui/tabs.test.tsx b/src/vertex/components/ui/tabs.test.tsx new file mode 100644 index 0000000000..f992e91867 --- /dev/null +++ b/src/vertex/components/ui/tabs.test.tsx @@ -0,0 +1,27 @@ +import { render, screen } from "@testing-library/react"; +import { describe, it, expect } from "vitest"; +import userEvent from "@testing-library/user-event"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "./tabs"; + +describe("Tabs", () => { + it("switches between tabs", async () => { + render( + + + Tab 1 + Tab 2 + + Content 1 + Content 2 + + ); + + expect(screen.getByText("Content 1")).toBeInTheDocument(); + expect(screen.queryByText("Content 2")).not.toBeInTheDocument(); + + await userEvent.click(screen.getByRole("tab", { name: "Tab 2" })); + + expect(screen.queryByText("Content 1")).not.toBeInTheDocument(); + expect(screen.getByText("Content 2")).toBeInTheDocument(); + }); +}); diff --git a/src/vertex/components/ui/textarea.test.tsx b/src/vertex/components/ui/textarea.test.tsx new file mode 100644 index 0000000000..957dfaa954 --- /dev/null +++ b/src/vertex/components/ui/textarea.test.tsx @@ -0,0 +1,16 @@ +import { render, screen } from "@testing-library/react"; +import { describe, it, expect } from "vitest"; +import userEvent from "@testing-library/user-event"; +import { Textarea } from "./textarea"; + +describe("Textarea", () => { + it("renders and handles input", async () => { + render(