From 4d632e7a5d96902be6e987412fe23b9ae11ada80 Mon Sep 17 00:00:00 2001 From: Shahed Nasser Date: Mon, 5 Jan 2026 10:56:56 +0200 Subject: [PATCH] docs: added tests for components in api-reference project (#14428) * add tests (WIP) * added test for h2 * finished adding tests * fixes * fixes * fixes --- .../components/Description/index.tsx | 1 + .../components/DividedLoading/index.tsx | 1 + .../DownloadFull/__tests__/index.test.tsx | 41 + .../components/DownloadFull/index.tsx | 10 +- .../Feedback/__tests__/index.test.tsx | 46 + .../components/Feedback/index.tsx | 3 +- .../MDXComponents/H2/__tests__/index.test.tsx | 92 ++ .../components/MDXComponents/H2/index.tsx | 2 +- .../Description/__tests__/index.test.tsx | 104 +++ .../Security/Description/index.tsx | 16 +- .../Security/__tests__/index.test.tsx | 69 ++ .../MDXComponents/Security/index.tsx | 1 + .../components/MDXComponents/index.tsx | 1 + .../components/MDXContent/Client/index.tsx | 1 + .../components/MDXContent/Server/index.tsx | 1 + .../MethodLabel/__tests__/index.test.tsx | 63 ++ .../components/MethodLabel/index.tsx | 1 + .../Container/__tests__/index.test.tsx | 59 ++ .../components/Section/Container/index.tsx | 2 + .../components/Section/Divider/index.tsx | 1 + .../Section/__tests__/index.test.tsx | 79 ++ .../components/Section/index.tsx | 1 + .../components/Space/__tests__/index.test.tsx | 39 + .../api-reference/components/Space/index.tsx | 3 + .../RequestSamples/__tests__/index.test.tsx | 85 ++ .../CodeSection/RequestSamples/index.tsx | 1 + .../Responses/Sample/__tests__/index.test.tsx | 170 ++++ .../CodeSection/Responses/Sample/index.tsx | 15 +- .../Responses/__tests__/index.test.tsx | 89 ++ .../Operation/CodeSection/Responses/index.tsx | 1 + .../CodeSection/__tests__/index.test.tsx | 107 +++ .../Tags/Operation/CodeSection/index.tsx | 13 +- .../__tests__/index.test.tsx | 54 ++ .../DeprecationNotice/index.tsx | 6 +- .../Events/__tests__/index.test.tsx | 246 ++++++ .../DescriptionSection/Events/index.tsx | 23 +- .../Parameters/__tests__/index.test.tsx | 135 +++ .../DescriptionSection/Parameters/index.tsx | 4 + .../DescriptionSection/RequestBody/index.tsx | 1 + .../Responses/__tests__/index.test.tsx | 150 ++++ .../DescriptionSection/Responses/index.tsx | 13 +- .../Security/__tests__/index.test.tsx | 73 ++ .../DescriptionSection/Security/index.tsx | 1 + .../WorkflowBadge/__tests__/index.test.tsx | 51 ++ .../WorkflowBadge/index.tsx | 3 +- .../__tests__/index.test.tsx | 339 +++++++ .../Operation/DescriptionSection/index.tsx | 23 +- .../__tests__/index.test.tsx | 69 ++ .../Operation/FeatureFlagNotice/index.tsx | 6 +- .../Description/__tests__/index.test.tsx | 154 ++++ .../Parameters/Description/index.tsx | 11 +- .../Parameters/Name/__tests__/index.test.tsx | 729 +++++++++++++++ .../Tags/Operation/Parameters/Name/index.tsx | 23 +- .../Operation/Parameters/Nested/index.tsx | 1 + .../Section/__tests__/index.test.tsx | 68 ++ .../Operation/Parameters/Section/index.tsx | 4 +- .../Types/Array/__tests__/index.test.tsx | 150 ++++ .../Parameters/Types/Array/index.tsx | 5 +- .../Types/Default/__tests__/index.test.tsx | 80 ++ .../Parameters/Types/Default/index.tsx | 2 + .../Types/Object/__tests__/index.test.tsx | 327 +++++++ .../Parameters/Types/Object/index.tsx | 11 +- .../Types/OneOf/__tests__/index.test.tsx | 140 +++ .../Parameters/Types/OneOf/index.tsx | 30 +- .../Types/Union/__tests__/index.test.tsx | 155 ++++ .../Parameters/Types/Union/index.tsx | 10 +- .../Parameters/__tests__/index.test.tsx | 293 +++++++ .../Tags/Operation/Parameters/index.tsx | 9 +- .../Tags/Operation/__tests__/index.test.tsx | 489 +++++++++++ .../components/Tags/Operation/index.tsx | 20 +- .../Tags/Paths/__tests__/index.test.tsx | 349 ++++++++ .../components/Tags/Paths/index.tsx | 50 +- .../RoutesSummary/__tests__/index.test.tsx | 98 +++ .../Tags/Section/RoutesSummary/index.tsx | 3 +- .../Section/Schema/__tests__/index.test.tsx | 592 +++++++++++++ .../components/Tags/Section/Schema/index.tsx | 3 +- .../Tags/Section/__tests__/index.test.tsx | 830 ++++++++++++++++++ .../components/Tags/Section/index.tsx | 8 +- .../components/Tags/__tests__/index.test.tsx | 49 ++ .../api-reference/components/Tags/index.tsx | 2 +- www/apps/api-reference/eslint.config.mjs | 2 + www/apps/api-reference/package.json | 7 +- .../providers/__tests__/area.test.tsx | 182 ++++ .../providers/__tests__/base-specs.test.tsx | 363 ++++++++ .../providers/__tests__/loading.test.tsx | 95 ++ .../providers/__tests__/main-nav.test.tsx | 80 ++ .../providers/__tests__/page-title.test.tsx | 206 +++++ .../providers/__tests__/search.test.tsx | 162 ++++ .../providers/__tests__/sidebar.test.tsx | 237 +++++ www/apps/api-reference/providers/area.tsx | 1 + .../api-reference/providers/base-specs.tsx | 4 +- www/apps/api-reference/providers/loading.tsx | 1 + www/apps/api-reference/providers/main-nav.tsx | 1 + .../api-reference/providers/page-title.tsx | 3 + www/apps/api-reference/providers/search.tsx | 1 + www/apps/api-reference/providers/sidebar.tsx | 1 + www/apps/api-reference/tsconfig.json | 4 +- www/apps/api-reference/tsconfig.tests.json | 16 + www/apps/api-reference/vitest.config.mts | 18 + .../AiAssistant/__tests__/index.test.tsx | 5 +- www/turbo.json | 4 +- www/yarn.lock | 2 + 102 files changed, 8278 insertions(+), 127 deletions(-) create mode 100644 www/apps/api-reference/components/DownloadFull/__tests__/index.test.tsx create mode 100644 www/apps/api-reference/components/Feedback/__tests__/index.test.tsx create mode 100644 www/apps/api-reference/components/MDXComponents/H2/__tests__/index.test.tsx create mode 100644 www/apps/api-reference/components/MDXComponents/Security/Description/__tests__/index.test.tsx create mode 100644 www/apps/api-reference/components/MDXComponents/Security/__tests__/index.test.tsx create mode 100644 www/apps/api-reference/components/MethodLabel/__tests__/index.test.tsx create mode 100644 www/apps/api-reference/components/Section/Container/__tests__/index.test.tsx create mode 100644 www/apps/api-reference/components/Section/__tests__/index.test.tsx create mode 100644 www/apps/api-reference/components/Space/__tests__/index.test.tsx create mode 100644 www/apps/api-reference/components/Tags/Operation/CodeSection/RequestSamples/__tests__/index.test.tsx create mode 100644 www/apps/api-reference/components/Tags/Operation/CodeSection/Responses/Sample/__tests__/index.test.tsx create mode 100644 www/apps/api-reference/components/Tags/Operation/CodeSection/Responses/__tests__/index.test.tsx create mode 100644 www/apps/api-reference/components/Tags/Operation/CodeSection/__tests__/index.test.tsx create mode 100644 www/apps/api-reference/components/Tags/Operation/DescriptionSection/DeprecationNotice/__tests__/index.test.tsx create mode 100644 www/apps/api-reference/components/Tags/Operation/DescriptionSection/Events/__tests__/index.test.tsx create mode 100644 www/apps/api-reference/components/Tags/Operation/DescriptionSection/Parameters/__tests__/index.test.tsx create mode 100644 www/apps/api-reference/components/Tags/Operation/DescriptionSection/Responses/__tests__/index.test.tsx create mode 100644 www/apps/api-reference/components/Tags/Operation/DescriptionSection/Security/__tests__/index.test.tsx create mode 100644 www/apps/api-reference/components/Tags/Operation/DescriptionSection/WorkflowBadge/__tests__/index.test.tsx create mode 100644 www/apps/api-reference/components/Tags/Operation/DescriptionSection/__tests__/index.test.tsx create mode 100644 www/apps/api-reference/components/Tags/Operation/FeatureFlagNotice/__tests__/index.test.tsx create mode 100644 www/apps/api-reference/components/Tags/Operation/Parameters/Description/__tests__/index.test.tsx create mode 100644 www/apps/api-reference/components/Tags/Operation/Parameters/Name/__tests__/index.test.tsx create mode 100644 www/apps/api-reference/components/Tags/Operation/Parameters/Section/__tests__/index.test.tsx create mode 100644 www/apps/api-reference/components/Tags/Operation/Parameters/Types/Array/__tests__/index.test.tsx create mode 100644 www/apps/api-reference/components/Tags/Operation/Parameters/Types/Default/__tests__/index.test.tsx create mode 100644 www/apps/api-reference/components/Tags/Operation/Parameters/Types/Object/__tests__/index.test.tsx create mode 100644 www/apps/api-reference/components/Tags/Operation/Parameters/Types/OneOf/__tests__/index.test.tsx create mode 100644 www/apps/api-reference/components/Tags/Operation/Parameters/Types/Union/__tests__/index.test.tsx create mode 100644 www/apps/api-reference/components/Tags/Operation/Parameters/__tests__/index.test.tsx create mode 100644 www/apps/api-reference/components/Tags/Operation/__tests__/index.test.tsx create mode 100644 www/apps/api-reference/components/Tags/Paths/__tests__/index.test.tsx create mode 100644 www/apps/api-reference/components/Tags/Section/RoutesSummary/__tests__/index.test.tsx create mode 100644 www/apps/api-reference/components/Tags/Section/Schema/__tests__/index.test.tsx create mode 100644 www/apps/api-reference/components/Tags/Section/__tests__/index.test.tsx create mode 100644 www/apps/api-reference/components/Tags/__tests__/index.test.tsx create mode 100644 www/apps/api-reference/providers/__tests__/area.test.tsx create mode 100644 www/apps/api-reference/providers/__tests__/base-specs.test.tsx create mode 100644 www/apps/api-reference/providers/__tests__/loading.test.tsx create mode 100644 www/apps/api-reference/providers/__tests__/main-nav.test.tsx create mode 100644 www/apps/api-reference/providers/__tests__/page-title.test.tsx create mode 100644 www/apps/api-reference/providers/__tests__/search.test.tsx create mode 100644 www/apps/api-reference/providers/__tests__/sidebar.test.tsx create mode 100644 www/apps/api-reference/tsconfig.tests.json create mode 100644 www/apps/api-reference/vitest.config.mts diff --git a/www/apps/api-reference/components/Description/index.tsx b/www/apps/api-reference/components/Description/index.tsx index 06f0496f49..1c14a65ea5 100644 --- a/www/apps/api-reference/components/Description/index.tsx +++ b/www/apps/api-reference/components/Description/index.tsx @@ -1,5 +1,6 @@ "use server" +import React from "react" import type { OpenAPI } from "types" import Section from "../Section" import MDXContentServer from "../MDXContent/Server" diff --git a/www/apps/api-reference/components/DividedLoading/index.tsx b/www/apps/api-reference/components/DividedLoading/index.tsx index 3785afbae9..41dc1caa36 100644 --- a/www/apps/api-reference/components/DividedLoading/index.tsx +++ b/www/apps/api-reference/components/DividedLoading/index.tsx @@ -1,4 +1,5 @@ import DividedLayout from "@/layouts/Divided" +import React from "react" import { Loading } from "docs-ui" type DividedLoadingProps = { diff --git a/www/apps/api-reference/components/DownloadFull/__tests__/index.test.tsx b/www/apps/api-reference/components/DownloadFull/__tests__/index.test.tsx new file mode 100644 index 0000000000..5766337e2d --- /dev/null +++ b/www/apps/api-reference/components/DownloadFull/__tests__/index.test.tsx @@ -0,0 +1,41 @@ +import React from "react" +import { beforeEach, describe, expect, test, vi } from "vitest" +import { cleanup, render } from "@testing-library/react" + +// mock hooks +const mockUseArea = vi.fn(() => ({ + area: "store", +})) + +// mock components +vi.mock("@/providers/area", () => ({ + useArea: () => mockUseArea(), +})) + +import DownloadFull from ".." + +beforeEach(() => { + cleanup() + vi.clearAllMocks() +}) + +describe("render", () => { + test("render download link for store area", () => { + const { getByTestId } = render() + const link = getByTestId("download-full-link") + expect(link).toBeInTheDocument() + expect(link).toHaveAttribute("href", "/download/store") + expect(link).toHaveAttribute("download") + expect(link).toHaveAttribute("target", "_blank") + }) + + test("render download link for admin area", () => { + mockUseArea.mockReturnValue({ area: "admin" }) + const { getByTestId } = render() + const link = getByTestId("download-full-link") + expect(link).toBeInTheDocument() + expect(link).toHaveAttribute("href", "/download/admin") + expect(link).toHaveAttribute("download") + expect(link).toHaveAttribute("target", "_blank") + }) +}) \ No newline at end of file diff --git a/www/apps/api-reference/components/DownloadFull/index.tsx b/www/apps/api-reference/components/DownloadFull/index.tsx index bf78d77cae..3c31c9d484 100644 --- a/www/apps/api-reference/components/DownloadFull/index.tsx +++ b/www/apps/api-reference/components/DownloadFull/index.tsx @@ -1,7 +1,8 @@ "use client" +import React from "react" import { Button } from "docs-ui" -import { useArea } from "../../providers/area" +import { useArea } from "@/providers/area" import Link from "next/link" const DownloadFull = () => { @@ -9,7 +10,12 @@ const DownloadFull = () => { return ( diff --git a/www/apps/api-reference/components/Feedback/__tests__/index.test.tsx b/www/apps/api-reference/components/Feedback/__tests__/index.test.tsx new file mode 100644 index 0000000000..a46f408dfa --- /dev/null +++ b/www/apps/api-reference/components/Feedback/__tests__/index.test.tsx @@ -0,0 +1,46 @@ +import React from "react" +import { beforeEach, describe, expect, test, vi } from "vitest" +import { cleanup, render } from "@testing-library/react" + +// mock hooks +const mockUseArea = vi.fn(() => ({ + area: "store", +})) + +// mock components +vi.mock("@/providers/area", () => ({ + useArea: () => mockUseArea(), +})) +vi.mock("docs-ui", async () => { + const actual = await vi.importActual("docs-ui") + return { + ...actual, + Feedback: vi.fn(({ extraData }: { extraData: { area: string } }) => ( +
Feedback
+ )), + } +}) + +import {Feedback} from ".." + +beforeEach(() => { + cleanup() + vi.clearAllMocks() +}) + +describe("render", () => { + test("render feedback component for store area", () => { + const { getByTestId } = render() + const feedback = getByTestId("feedback") + expect(feedback).toBeInTheDocument() + expect(feedback).toHaveAttribute("data-area", "store") + }) + + test("render feedback component for admin area", () => { + mockUseArea.mockReturnValue({ area: "admin" }) + const { getByTestId } = render() + const feedback = getByTestId("feedback") + expect(feedback).toBeInTheDocument() + expect(feedback).toHaveAttribute("data-area", "admin") + }) +}) \ No newline at end of file diff --git a/www/apps/api-reference/components/Feedback/index.tsx b/www/apps/api-reference/components/Feedback/index.tsx index 083b285acf..5aa732b1e9 100644 --- a/www/apps/api-reference/components/Feedback/index.tsx +++ b/www/apps/api-reference/components/Feedback/index.tsx @@ -1,11 +1,12 @@ "use client" +import React from "react" import { Feedback as UiFeedback, FeedbackProps, DocsTrackingEvents, } from "docs-ui" -import { useArea } from "../../providers/area" +import { useArea } from "@/providers/area" export const Feedback = (props: Partial) => { const { area } = useArea() diff --git a/www/apps/api-reference/components/MDXComponents/H2/__tests__/index.test.tsx b/www/apps/api-reference/components/MDXComponents/H2/__tests__/index.test.tsx new file mode 100644 index 0000000000..4c4571a8f4 --- /dev/null +++ b/www/apps/api-reference/components/MDXComponents/H2/__tests__/index.test.tsx @@ -0,0 +1,92 @@ +import React from "react" +import { beforeEach, describe, expect, test, vi } from "vitest" +import { cleanup, render } from "@testing-library/react" + +// mock functions +const mockScrollToElement = vi.fn() +const mockUseScrollController = vi.fn(() => ({ + scrollableElement: null as HTMLElement | null, + scrollToElement: mockScrollToElement, +})) +const mockUseSidebar = vi.fn(() => ({ + activePath: null as string | null, +})) + +// mock components +vi.mock("docs-ui", () => ({ + useScrollController: () => mockUseScrollController(), + useSidebar: () => mockUseSidebar(), + H2: ({ + passRef, + ...props + }: { + passRef: React.RefObject + } & React.HTMLAttributes) => ( +

+ ), +})) +vi.mock("docs-utils", () => ({ + getSectionId: vi.fn(() => "section-id"), +})) + +import H2 from ".." + +beforeEach(() => { + vi.clearAllMocks() + cleanup() +}) + +describe("render", () => { + test("renders children", () => { + const { container } = render(

Test

) + const h2 = container.querySelector("h2") + expect(h2).toBeInTheDocument() + expect(h2).toHaveTextContent("Test") + }) + + test("renders with custom props", () => { + const { container } = render(

Test

) + const h2 = container.querySelector("h2") + expect(h2).toBeInTheDocument() + expect(h2).toHaveClass("test-class") + }) +}) + +describe("section id", () => { + test("uses generated id", () => { + const { container } = render(

Test

) + const h2 = container.querySelector("h2") + expect(h2).toBeInTheDocument() + expect(h2).toHaveAttribute("id", "section-id") + }) +}) + +describe("scroll", () => { + test("scrolls to element when active path matches id", () => { + mockUseScrollController.mockReturnValueOnce({ + scrollableElement: document.body, + scrollToElement: mockScrollToElement, + }) + mockUseSidebar.mockReturnValueOnce({ + activePath: "section-id", + }) + render(

Test

) + const heading = document.querySelector("h2") + expect(heading).toBeInTheDocument() + expect(mockScrollToElement).toHaveBeenCalledWith(heading) + }) + + test("does not scroll to element when active path does not match id", () => { + mockUseScrollController.mockReturnValueOnce({ + scrollableElement: document.body, + scrollToElement: mockScrollToElement, + }) + mockUseSidebar.mockReturnValueOnce({ + activePath: "other-section-id", + }) + render(

Test

) + const heading = document.querySelector("h2") + expect(heading).toBeInTheDocument() + expect(mockScrollToElement).not.toHaveBeenCalled() + }) +}) \ No newline at end of file diff --git a/www/apps/api-reference/components/MDXComponents/H2/index.tsx b/www/apps/api-reference/components/MDXComponents/H2/index.tsx index ee181b18b5..749c1606ee 100644 --- a/www/apps/api-reference/components/MDXComponents/H2/index.tsx +++ b/www/apps/api-reference/components/MDXComponents/H2/index.tsx @@ -1,8 +1,8 @@ "use client" +import React, { useEffect, useMemo, useRef, useState } from "react" import { useScrollController, useSidebar, H2 as UiH2 } from "docs-ui" import { getSectionId } from "docs-utils" -import { useEffect, useMemo, useRef, useState } from "react" type H2Props = React.HTMLAttributes diff --git a/www/apps/api-reference/components/MDXComponents/Security/Description/__tests__/index.test.tsx b/www/apps/api-reference/components/MDXComponents/Security/Description/__tests__/index.test.tsx new file mode 100644 index 0000000000..3b43e330e2 --- /dev/null +++ b/www/apps/api-reference/components/MDXComponents/Security/Description/__tests__/index.test.tsx @@ -0,0 +1,104 @@ +import React from "react" +import { beforeEach, describe, expect, test, vi } from "vitest" +import { cleanup, getByTestId, render, waitFor } from "@testing-library/react" +import { OpenAPI } from "types" + +// mock data +const mockHttpSecuritySchema: OpenAPI.SecuritySchemeObject = { + type: "http", + scheme: "bearer", + description: "Authentication using Bearer token", + "x-displayName": "Bearer Token", +} +const mockApiKeySecuritySchema: OpenAPI.SecuritySchemeObject = { + type: "apiKey", + name: "Authorization", + description: "Authentication using API key", + "x-displayName": "API Key", + in: "header", +} + +// mock components +vi.mock("@/components/MDXContent/Client", () => ({ + default: ({ content }: { content: string }) => ( +
{content}
+ ), +})) +vi.mock("@/components/MDXContent/Server", () => ({ + default: ({ content }: { content: string }) => ( +
{content}
+ ), +})) +vi.mock("@/utils/get-security-schema-type-name", () => ({ + default: vi.fn(() => "security-schema-type"), +})) +vi.mock("docs-ui", () => ({ + Loading: () =>
Loading
, +})) + +import SecurityDescription from ".." + +beforeEach(() => { + vi.clearAllMocks() + cleanup() +}) + +describe("rendering", () => { + test("renders security information for http security scheme", () => { + const { getByTestId } = render( + + ) + const titleElement = getByTestId("title") + expect(titleElement).toBeInTheDocument() + expect(titleElement).toHaveTextContent(mockHttpSecuritySchema["x-displayName"] as string) + const securitySchemeTypeElement = getByTestId("security-scheme-type") + expect(securitySchemeTypeElement).toBeInTheDocument() + expect(securitySchemeTypeElement).toHaveTextContent("security-schema-type") + const securitySchemeTypeDetailsElement = getByTestId("security-scheme-type-details") + expect(securitySchemeTypeDetailsElement).toBeInTheDocument() + expect(securitySchemeTypeDetailsElement).toHaveTextContent("HTTP Authorization Scheme") + const securitySchemeTypeDetailsValueElement = getByTestId("security-scheme-type-details-value") + expect(securitySchemeTypeDetailsValueElement).toBeInTheDocument() + expect(securitySchemeTypeDetailsValueElement).toHaveTextContent(mockHttpSecuritySchema.scheme) + }) + + test("renders security information for api key security scheme", () => { + const { getByTestId } = render( + + ) + const titleElement = getByTestId("title") + expect(titleElement).toBeInTheDocument() + expect(titleElement).toHaveTextContent(mockApiKeySecuritySchema["x-displayName"] as string) + const securitySchemeTypeElement = getByTestId("security-scheme-type") + expect(securitySchemeTypeElement).toBeInTheDocument() + expect(securitySchemeTypeElement).toHaveTextContent("security-schema-type") + const securitySchemeTypeDetailsElement = getByTestId("security-scheme-type-details") + expect(securitySchemeTypeDetailsElement).toBeInTheDocument() + expect(securitySchemeTypeDetailsElement).toHaveTextContent("Cookie parameter name") + const securitySchemeTypeDetailsValueElement = getByTestId("security-scheme-type-details-value") + expect(securitySchemeTypeDetailsValueElement).toBeInTheDocument() + expect(securitySchemeTypeDetailsValueElement).toHaveTextContent(mockApiKeySecuritySchema.name) + }) + + test("render description with server component", async () => { + const { getByTestId } = render( + + ) + await waitFor(() => { + const mdxContentServerElement = getByTestId("mdx-content-server") + expect(mdxContentServerElement).toBeInTheDocument() + expect(mdxContentServerElement).toHaveTextContent(mockHttpSecuritySchema.description as string) + }) + }) + + test("render description with client component", async () => { + const { getByTestId } = render( + + ) + await waitFor(() => { + const mdxContentClientElement = getByTestId("mdx-content-client") + expect(mdxContentClientElement).toBeInTheDocument() + expect(mdxContentClientElement).toHaveTextContent(mockHttpSecuritySchema.description as string) + }) + }) +}) diff --git a/www/apps/api-reference/components/MDXComponents/Security/Description/index.tsx b/www/apps/api-reference/components/MDXComponents/Security/Description/index.tsx index f30fb0c08b..cfd4f69bf8 100644 --- a/www/apps/api-reference/components/MDXComponents/Security/Description/index.tsx +++ b/www/apps/api-reference/components/MDXComponents/Security/Description/index.tsx @@ -1,3 +1,4 @@ +import React from "react" import type { MDXContentClientProps } from "@/components/MDXContent/Client" import type { MDXContentServerProps } from "@/components/MDXContent/Server" import type { OpenAPI } from "types" @@ -7,14 +8,14 @@ import { Loading } from "docs-ui" import dynamic from "next/dynamic" const MDXContentClient = dynamic( - async () => import("../../../MDXContent/Client"), + async () => import("@/components/MDXContent/Client"), { loading: () => , } ) as React.FC const MDXContentServer = dynamic( - async () => import("../../../MDXContent/Server"), + async () => import("@/components/MDXContent/Server"), { loading: () => , } @@ -31,22 +32,25 @@ const SecurityDescription = ({ }: SecurityDescriptionProps) => { return ( <> -

{securitySchema["x-displayName"] as string}

+

{securitySchema["x-displayName"] as string}

{isServer && } {!isServer && } -

+

Security Scheme Type:{" "} {getSecuritySchemaTypeName(securitySchema)}

{(securitySchema.type === "http" || securitySchema.type === "apiKey") && ( -

+

{securitySchema.type === "http" ? "HTTP Authorization Scheme" : "Cookie parameter name"} : {" "} - + {securitySchema.type === "http" ? securitySchema.scheme : securitySchema.name} diff --git a/www/apps/api-reference/components/MDXComponents/Security/__tests__/index.test.tsx b/www/apps/api-reference/components/MDXComponents/Security/__tests__/index.test.tsx new file mode 100644 index 0000000000..9b9ec32a93 --- /dev/null +++ b/www/apps/api-reference/components/MDXComponents/Security/__tests__/index.test.tsx @@ -0,0 +1,69 @@ +import React from "react" +import { beforeEach, describe, expect, test, vi } from "vitest" +import { cleanup, render, waitFor } from "@testing-library/react" +import { OpenAPI } from "types" + +// mock data +const mockSpecs: OpenAPI.OpenAPIV3.Document = { + openapi: "3.0.0", + info: { + title: "Test API", + version: "1.0.0", + }, + paths: {}, + components: { + securitySchemes: { + bearer: { + type: "http", + scheme: "bearer", + }, + }, + }, +} + +const mockSpecsWithRef: OpenAPI.OpenAPIV3.Document = { + ...mockSpecs, + components: { + securitySchemes: { + bearer: { + $ref: "#/components/securitySchemes/bearer", + }, + }, + }, +} + +// mock components +vi.mock("@/components/MDXComponents/Security/Description", () => ({ + default: ({ securitySchema }: { securitySchema: OpenAPI.SecuritySchemeObject }) => ( +

{securitySchema.type}
+ ), +})) + +import Security from ".." + +beforeEach(() => { + vi.clearAllMocks() + cleanup() +}) + +describe("rendering", () => { + test("does not render when specs is not provided", () => { + const { container } = render() + const securityDescriptionElements = container.querySelectorAll("[data-testid='security-description']") + expect(securityDescriptionElements).toHaveLength(0) + }) + test("renders security information for specs", async () => { + const { getByTestId } = render() + await waitFor(() => { + const securityDescriptionElement = getByTestId("security-description") + expect(securityDescriptionElement).toBeInTheDocument() + expect(securityDescriptionElement).toHaveTextContent("http") + }) + }) + + test("does not render when security scheme is a $ref", () => { + const { container } = render() + const securityDescriptionElements = container.querySelectorAll("[data-testid='security-description']") + expect(securityDescriptionElements).toHaveLength(0) + }) +}) \ No newline at end of file diff --git a/www/apps/api-reference/components/MDXComponents/Security/index.tsx b/www/apps/api-reference/components/MDXComponents/Security/index.tsx index 41104f900a..8e28c72c55 100644 --- a/www/apps/api-reference/components/MDXComponents/Security/index.tsx +++ b/www/apps/api-reference/components/MDXComponents/Security/index.tsx @@ -1,3 +1,4 @@ +import React from "react" import dynamic from "next/dynamic" import type { OpenAPI } from "types" import type { SecurityDescriptionProps } from "./Description" diff --git a/www/apps/api-reference/components/MDXComponents/index.tsx b/www/apps/api-reference/components/MDXComponents/index.tsx index 7a5476cb3a..28fabe17a6 100644 --- a/www/apps/api-reference/components/MDXComponents/index.tsx +++ b/www/apps/api-reference/components/MDXComponents/index.tsx @@ -1,3 +1,4 @@ +import React from "react" import type { MDXComponents } from "mdx/types" import Security from "./Security" import type { OpenAPI } from "types" diff --git a/www/apps/api-reference/components/MDXContent/Client/index.tsx b/www/apps/api-reference/components/MDXContent/Client/index.tsx index bc0642e04c..70230c66d5 100644 --- a/www/apps/api-reference/components/MDXContent/Client/index.tsx +++ b/www/apps/api-reference/components/MDXContent/Client/index.tsx @@ -1,5 +1,6 @@ "use client" +import React from "react" import { useEffect, useState } from "react" import getCustomComponents from "../../MDXComponents" import type { ScopeType } from "../../MDXComponents" diff --git a/www/apps/api-reference/components/MDXContent/Server/index.tsx b/www/apps/api-reference/components/MDXContent/Server/index.tsx index 9e4722b7bd..dcae58e6c0 100644 --- a/www/apps/api-reference/components/MDXContent/Server/index.tsx +++ b/www/apps/api-reference/components/MDXContent/Server/index.tsx @@ -1,6 +1,7 @@ /* eslint-disable @typescript-eslint/ban-ts-comment */ "use server" +import React from "react" import { MDXRemote } from "next-mdx-remote/rsc" import getCustomComponents from "../../MDXComponents" import type { ScopeType } from "../../MDXComponents" diff --git a/www/apps/api-reference/components/MethodLabel/__tests__/index.test.tsx b/www/apps/api-reference/components/MethodLabel/__tests__/index.test.tsx new file mode 100644 index 0000000000..eb51933d94 --- /dev/null +++ b/www/apps/api-reference/components/MethodLabel/__tests__/index.test.tsx @@ -0,0 +1,63 @@ +import React from "react" +import { beforeEach, describe, expect, test, vi } from "vitest" +import { cleanup, render } from "@testing-library/react" + +// mock data +const mockMethod = "get" +const mockMethod2 = "post" +const mockMethod3 = "delete" + +// mock components +vi.mock("docs-ui", () => ({ + Badge: ({ + children, + variant, + className + }: { + children: React.ReactNode, + variant: string, + className: string + }) => ( +
{children}
+ ), + capitalize: vi.fn((text: string) => text.charAt(0).toUpperCase() + text.slice(1)), +})) + +import MethodLabel from ".." + +beforeEach(() => { + vi.clearAllMocks() + cleanup() +}) + +describe("rendering", () => { + test("renders method label for get method", () => { + const { getByTestId } = render() + const badgeElement = getByTestId("badge") + expect(badgeElement).toBeInTheDocument() + expect(badgeElement).toHaveTextContent("Get") + expect(badgeElement).toHaveAttribute("data-variant", "green") + }) + test("renders method label for post method", () => { + const { getByTestId } = render() + const badgeElement = getByTestId("badge") + expect(badgeElement).toBeInTheDocument() + expect(badgeElement).toHaveTextContent("Post") + expect(badgeElement).toHaveAttribute("data-variant", "blue") + }) + test("renders method label for delete method", () => { + const { getByTestId } = render() + const badgeElement = getByTestId("badge") + expect(badgeElement).toBeInTheDocument() + expect(badgeElement).toHaveTextContent("Del") + expect(badgeElement).toHaveAttribute("data-variant", "red") + }) + test("renders method label with className", () => { + const { getByTestId } = render() + const badgeElement = getByTestId("badge") + expect(badgeElement).toBeInTheDocument() + expect(badgeElement).toHaveTextContent("Get") + expect(badgeElement).toHaveAttribute("data-variant", "green") + expect(badgeElement).toHaveClass("test-class") + }) +}) \ No newline at end of file diff --git a/www/apps/api-reference/components/MethodLabel/index.tsx b/www/apps/api-reference/components/MethodLabel/index.tsx index d5b7241929..811cf2ffe5 100644 --- a/www/apps/api-reference/components/MethodLabel/index.tsx +++ b/www/apps/api-reference/components/MethodLabel/index.tsx @@ -1,3 +1,4 @@ +import React from "react" import clsx from "clsx" import { Badge, capitalize } from "docs-ui" diff --git a/www/apps/api-reference/components/Section/Container/__tests__/index.test.tsx b/www/apps/api-reference/components/Section/Container/__tests__/index.test.tsx new file mode 100644 index 0000000000..b7be07a8ae --- /dev/null +++ b/www/apps/api-reference/components/Section/Container/__tests__/index.test.tsx @@ -0,0 +1,59 @@ +import React from "react" +import { beforeEach, describe, expect, test, vi } from "vitest" +import { cleanup, render } from "@testing-library/react" + +// mock components +vi.mock("@/components/Section/Divider", () => ({ + default: () =>
Section Divider
, +})) +vi.mock("docs-ui", () => ({ + WideSection: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), +})) + +import SectionContainer from ".." + +beforeEach(() => { + vi.clearAllMocks() + cleanup() +}) + +describe("rendering", () => { + test("renders section container with children", () => { + const { getByTestId } = render(Test) + const wideSectionElement = getByTestId("wide-section") + expect(wideSectionElement).toBeInTheDocument() + expect(wideSectionElement).toHaveTextContent("Test") + }) + test("renders section container with no top padding", () => { + const { getByTestId } = render(Test) + const sectionContainerElement = getByTestId("section-container") + expect(sectionContainerElement).not.toHaveClass("pt-7") + }) + test("renders section container with top padding", () => { + const { getByTestId } = render(Test) + const sectionContainerElement = getByTestId("section-container") + expect(sectionContainerElement).toHaveClass("pt-7") + }) + test("renders section container with divider", () => { + const { getByTestId } = render(Test) + const sectionDividerElement = getByTestId("section-divider") + expect(sectionDividerElement).toBeInTheDocument() + }) + test("renders section container with no divider", () => { + const { container } = render(Test) + const sectionDividerElement = container.querySelectorAll("[data-testid='section-divider']") + expect(sectionDividerElement).toHaveLength(0) + }) + test("renders section container with className", () => { + const { getByTestId } = render(Test) + const sectionContainerElement = getByTestId("section-container") + expect(sectionContainerElement).toHaveClass("test-class") + }) + test("renders section container with ref", () => { + const ref = vi.fn() + render(Test) + expect(ref).toHaveBeenCalled() + }) +}) \ No newline at end of file diff --git a/www/apps/api-reference/components/Section/Container/index.tsx b/www/apps/api-reference/components/Section/Container/index.tsx index 6775e13445..b3fab4f3c6 100644 --- a/www/apps/api-reference/components/Section/Container/index.tsx +++ b/www/apps/api-reference/components/Section/Container/index.tsx @@ -1,3 +1,4 @@ +import React from "react" import clsx from "clsx" import SectionDivider from "../Divider" import { forwardRef } from "react" @@ -23,6 +24,7 @@ const SectionContainer = forwardRef( !noTopPadding && "pt-7", className )} + data-testid="section-container" > {children} {!noDivider && } diff --git a/www/apps/api-reference/components/Section/Divider/index.tsx b/www/apps/api-reference/components/Section/Divider/index.tsx index 2119d3ac6e..5b8b3353a1 100644 --- a/www/apps/api-reference/components/Section/Divider/index.tsx +++ b/www/apps/api-reference/components/Section/Divider/index.tsx @@ -1,3 +1,4 @@ +import React from "react" import clsx from "clsx" type SectionDividerProps = { diff --git a/www/apps/api-reference/components/Section/__tests__/index.test.tsx b/www/apps/api-reference/components/Section/__tests__/index.test.tsx new file mode 100644 index 0000000000..325e61adca --- /dev/null +++ b/www/apps/api-reference/components/Section/__tests__/index.test.tsx @@ -0,0 +1,79 @@ +import React from "react" +import { beforeEach, describe, expect, test, vi } from "vitest" +import { cleanup, render } from "@testing-library/react" + +// mock functions +const mockSetActivePath = vi.fn() +const mockPush = vi.fn() +const mockReplace = vi.fn() +const mockUseActiveOnScroll = vi.fn((options: unknown) => ({ + activeItemId: "", +})) +const mockUseSidebar = vi.fn(() => ({ + setActivePath: mockSetActivePath, +})) +const mockUseRouter = vi.fn(() => ({ + push: mockPush, + replace: mockReplace, +})) + +// mock components +vi.mock("docs-ui", () => ({ + useActiveOnScroll: (options: unknown) => mockUseActiveOnScroll(options), + useSidebar: () => mockUseSidebar(), +})) +vi.mock("next/navigation", () => ({ + useRouter: () => mockUseRouter(), +})) + +import Section from ".." + +beforeEach(() => { + vi.clearAllMocks() + cleanup() + + window.history.scrollRestoration = "auto" +}) + +describe("rendering", () => { + test("passes checkActiveOnScroll prop to useActiveOnScroll", () => { + render(
Test
) + expect(mockUseActiveOnScroll).toHaveBeenCalledWith({ + rootElm: undefined, + enable: true, + useDefaultIfNoActive: false, + maxLevel: 2, + }) + }) +}) + +describe("effect hooks", () => { + test("sets active path when active item id is not empty", () => { + mockUseActiveOnScroll.mockReturnValue({ + activeItemId: "test", + }) + render(
Test
) + expect(mockSetActivePath).toHaveBeenCalledWith("test") + expect(mockPush).toHaveBeenCalledWith("#test", { scroll: false }) + }) + + test("does not set active path when active item id is empty", () => { + mockUseActiveOnScroll.mockReturnValue({ + activeItemId: "", + }) + render(
Test
) + expect(mockSetActivePath).not.toHaveBeenCalled() + expect(mockPush).not.toHaveBeenCalled() + }) + + test("disables scroll restoration when history is available", () => { + render(
Test
) + expect(history.scrollRestoration).toBe("manual") + }) + + test("does not disable scroll restoration when history is not available", () => { + delete (window.history as any).scrollRestoration + render(
Test
) + expect(history.scrollRestoration).not.toBe("manual") + }) +}) \ No newline at end of file diff --git a/www/apps/api-reference/components/Section/index.tsx b/www/apps/api-reference/components/Section/index.tsx index 288cf926ef..8b62554fd5 100644 --- a/www/apps/api-reference/components/Section/index.tsx +++ b/www/apps/api-reference/components/Section/index.tsx @@ -1,5 +1,6 @@ "use client" +import React from "react" import clsx from "clsx" import { useActiveOnScroll, useSidebar } from "docs-ui" import { useRouter } from "next/navigation" diff --git a/www/apps/api-reference/components/Space/__tests__/index.test.tsx b/www/apps/api-reference/components/Space/__tests__/index.test.tsx new file mode 100644 index 0000000000..01ff32c9e6 --- /dev/null +++ b/www/apps/api-reference/components/Space/__tests__/index.test.tsx @@ -0,0 +1,39 @@ +import React from "react" +import { beforeEach, describe, expect, test, vi } from "vitest" +import { cleanup, render } from "@testing-library/react" + + +import Space from ".." + +beforeEach(() => { + vi.clearAllMocks() + cleanup() +}) + +describe("rendering", () => { + test("renders space with default styles", () => { + const { getByTestId } = render() + const spaceElement = getByTestId("space") + expect(spaceElement).toBeInTheDocument() + expect(spaceElement).toHaveStyle({ + height: "1px", + marginTop: "0px", + marginBottom: "0px", + marginLeft: "0px", + marginRight: "0px", + }) + }) + + test("renders space with correct styles", () => { + const { getByTestId } = render() + const spaceElement = getByTestId("space") + expect(spaceElement).toBeInTheDocument() + expect(spaceElement).toHaveStyle({ + height: "1px", + marginTop: "9px", + marginBottom: "9px", + marginLeft: "10px", + marginRight: "10px", + }) + }) +}) \ No newline at end of file diff --git a/www/apps/api-reference/components/Space/index.tsx b/www/apps/api-reference/components/Space/index.tsx index 527e20eb1b..f74bd4c047 100644 --- a/www/apps/api-reference/components/Space/index.tsx +++ b/www/apps/api-reference/components/Space/index.tsx @@ -1,3 +1,5 @@ +import React from "react" + type SpaceProps = { top?: number bottom?: number @@ -16,6 +18,7 @@ const Space = ({ top = 0, bottom = 0, left = 0, right = 0 }: SpaceProps) => { marginLeft: `${left}px`, marginRight: `${right}px`, }} + data-testid="space" > ) } diff --git a/www/apps/api-reference/components/Tags/Operation/CodeSection/RequestSamples/__tests__/index.test.tsx b/www/apps/api-reference/components/Tags/Operation/CodeSection/RequestSamples/__tests__/index.test.tsx new file mode 100644 index 0000000000..8f9a138d53 --- /dev/null +++ b/www/apps/api-reference/components/Tags/Operation/CodeSection/RequestSamples/__tests__/index.test.tsx @@ -0,0 +1,85 @@ +import React from "react" +import { beforeEach, describe, expect, test, vi } from "vitest" +import { cleanup, render } from "@testing-library/react" + +// mock data +const mockCodeSamples = [ + { + label: "Request Sample 1", + lang: "javascript", + source: "console.log('Request Sample 1')", + }, + { + label: "Request Sample 2", + lang: "bash", + source: "echo 'Request Sample 2'", + }, +] + +// mock components +vi.mock("docs-ui", () => ({ + CodeBlock: ({ + source, + collapsed, + className, + lang + }: { + source: string, + collapsed: boolean, + className: string + lang: string + }) => ( +
{source}
+ ), + CodeTab: ({ + children, + label, + value + }: { + children: React.ReactNode, + label: string, + value: string + }) => ( +
{children}
+ ), + CodeTabs: ({ + children, + group + }: { + children: React.ReactNode, + group: string + }) => ( +
{children}
+ ), +})) +vi.mock("slugify", () => ({ + default: vi.fn((text: string) => text.toLowerCase()), +})) + +import TagOperationCodeSectionRequestSamples from ".." + +beforeEach(() => { + vi.clearAllMocks() + cleanup() +}) + +describe("rendering", () => { + test("renders code tabs and blocks for each code sample", () => { + const { getByTestId, getAllByTestId } = render( + + ) + const codeTabsElement = getByTestId("code-tabs") + expect(codeTabsElement).toBeInTheDocument() + expect(codeTabsElement).toHaveAttribute("data-group", "request-examples") + const codeBlocksElement = getAllByTestId("code-block") + expect(codeBlocksElement).toHaveLength(mockCodeSamples.length) + expect(codeBlocksElement[0]).toHaveAttribute("data-collapsed", "true") + expect(codeBlocksElement[1]).toHaveAttribute("data-collapsed", "true") + expect(codeBlocksElement[0]).toHaveClass("!mb-0") + expect(codeBlocksElement[1]).toHaveClass("!mb-0") + expect(codeBlocksElement[0]).toHaveAttribute("data-lang", mockCodeSamples[0].lang) + expect(codeBlocksElement[1]).toHaveAttribute("data-lang", mockCodeSamples[1].lang) + expect(codeBlocksElement[0]).toHaveTextContent(mockCodeSamples[0].source) + expect(codeBlocksElement[1]).toHaveTextContent(mockCodeSamples[1].source) + }) +}) \ No newline at end of file diff --git a/www/apps/api-reference/components/Tags/Operation/CodeSection/RequestSamples/index.tsx b/www/apps/api-reference/components/Tags/Operation/CodeSection/RequestSamples/index.tsx index 791d54e7cc..94929a94d2 100644 --- a/www/apps/api-reference/components/Tags/Operation/CodeSection/RequestSamples/index.tsx +++ b/www/apps/api-reference/components/Tags/Operation/CodeSection/RequestSamples/index.tsx @@ -1,3 +1,4 @@ +import React from "react" import type { OpenAPI } from "types" import { CodeBlock, CodeTab, CodeTabs } from "docs-ui" import slugify from "slugify" diff --git a/www/apps/api-reference/components/Tags/Operation/CodeSection/Responses/Sample/__tests__/index.test.tsx b/www/apps/api-reference/components/Tags/Operation/CodeSection/Responses/Sample/__tests__/index.test.tsx new file mode 100644 index 0000000000..d92d44b4e6 --- /dev/null +++ b/www/apps/api-reference/components/Tags/Operation/CodeSection/Responses/Sample/__tests__/index.test.tsx @@ -0,0 +1,170 @@ +import React from "react" +import { beforeEach, describe, expect, test, vi } from "vitest" +import { cleanup, fireEvent, render } from "@testing-library/react" +import { OpenAPI } from "types" + +// mock data +const mockResponse: OpenAPI.ResponseObject = { + description: "Mock response", + content: { + "application/json": { + schema: { + type: "object", + properties: { + name: { + type: "string", + properties: {} + }, + age: { + type: "number", + properties: {} + } + }, + }, + }, + }, +} +const mockExamples: OpenAPI.ExampleObject[] = [ + { + title: "example 1", + value: "example 1", + content: "Example 1", + }, +] + +// mock function +const mockUseSchemaExample = vi.fn((options: unknown) => ({ + examples: mockExamples +})) + +// mock components and hooks +vi.mock("docs-ui", () => ({ + CodeBlock: ({ source, collapsed, className, lang }: { source: string, collapsed: boolean, className: string, lang: string }) => ( +
{source}
+ ), +})) +vi.mock("@/hooks/use-schema-example", () => ({ + default: (options: unknown) => mockUseSchemaExample(options), +})) + +import TagsOperationCodeSectionResponsesSample from ".." + +beforeEach(() => { + vi.clearAllMocks() + cleanup() +}) + +describe("rendering", () => { + test("selects first example by default", () => { + const { getByTestId, container } = render( + + ) + const codeBlockElement = getByTestId("code-block") + expect(codeBlockElement).toBeInTheDocument() + expect(codeBlockElement).toHaveAttribute("data-collapsed", "true") + expect(codeBlockElement).toHaveAttribute("data-lang", "json") + expect(codeBlockElement).toHaveTextContent("Example 1") + }) + + test("renders empty response when no examples are available", () => { + mockUseSchemaExample.mockReturnValue({ + examples: [], + }) + const { container } = render( + + ) + expect(container).toHaveTextContent("Empty Response") + }) + + test("renders content type when content is available", () => { + const { container } = render( + + ) + const contentTypeElement = container.querySelector("[data-testid='content-type']") + expect(contentTypeElement).toBeInTheDocument() + expect(contentTypeElement).toHaveTextContent("Content type: application/json") + }) + + test("doesn't render content type when content is empty", () => { + const modifiedResponse: OpenAPI.ResponseObject = { + ...mockResponse, + content: {}, + } + const { container } = render( + + ) + const contentTypeElement = container.querySelector("[data-testid='content-type']") + expect(contentTypeElement).not.toBeInTheDocument() + }) + + test("doesn't render select for single example", () => { + mockUseSchemaExample.mockReturnValue({ + examples: [ + { title: "example 1", value: "example 1", content: "Example 1" }, + ], + }) + const { container } = render( + + ) + const selectElement = container.querySelector("select") + expect(selectElement).not.toBeInTheDocument() + }) + + test("renders select for multiple examples", () => { + mockUseSchemaExample.mockReturnValue({ + examples: [ + { title: "example 1", value: "example 1", content: "Example 1" }, + { title: "example 2", value: "example 2", content: "Example 2" }, + ], + }) + const { container } = render( + + ) + const selectElement = container.querySelector("select") + expect(selectElement).toBeInTheDocument() + const options = selectElement!.querySelectorAll("option") + expect(options).toHaveLength(2) + expect(options[0]).toHaveValue("example 1") + expect(options[1]).toHaveValue("example 2") + expect(options[0]).toHaveTextContent("example 1") + expect(options[1]).toHaveTextContent("example 2") + }) + + test("renders empty response when no example is selected", () => { + mockUseSchemaExample.mockReturnValue({ + examples: [ + { title: "example 1", value: "example 1", content: "Example 1" }, + { title: "example 2", value: "example 2", content: "Example 2" }, + ], + }) + const { container } = render( + + ) + const selectElement = container.querySelector("select") + expect(selectElement).toBeInTheDocument() + fireEvent.change(selectElement!, { target: { value: "example 3" } }) + const codeBlockElement = container.querySelector( + "[data-testid='code-block']" + ) + expect(codeBlockElement).not.toBeInTheDocument() + expect(container).toHaveTextContent("Empty Response") + }) +}) + +describe("interaction", () => { + test("renders code block for selected example when select is changed", () => { + mockUseSchemaExample.mockReturnValue({ + examples: [ + { title: "example 1", value: "example 1", content: "Example 1" }, + { title: "example 2", value: "example 2", content: "Example 2" }, + ], + }) + const { container, getByTestId } = render( + + ) + const selectElement = container.querySelector("select") + fireEvent.change(selectElement!, { target: { value: "example 2" } }) + const codeBlockElement = getByTestId("code-block") + expect(codeBlockElement).toHaveTextContent("Example 2") + }) +}) \ No newline at end of file diff --git a/www/apps/api-reference/components/Tags/Operation/CodeSection/Responses/Sample/index.tsx b/www/apps/api-reference/components/Tags/Operation/CodeSection/Responses/Sample/index.tsx index 76009b19d8..53203cbb6f 100644 --- a/www/apps/api-reference/components/Tags/Operation/CodeSection/Responses/Sample/index.tsx +++ b/www/apps/api-reference/components/Tags/Operation/CodeSection/Responses/Sample/index.tsx @@ -1,7 +1,8 @@ +import React from "react" import { CodeBlock } from "docs-ui" import type { OpenAPI } from "types" import { useEffect, useState } from "react" -import useSchemaExample from "../../../../../../hooks/use-schema-example" +import useSchemaExample from "@/hooks/use-schema-example" export type TagsOperationCodeSectionResponsesSampleProps = { response: OpenAPI.ResponseObject @@ -27,11 +28,17 @@ const TagsOperationCodeSectionResponsesSample = ({ setSelectedExample(examples[0]) }, [examples]) + const isEmptyResponse = + response?.content === undefined || + Object.keys(response.content).length === 0 + return ( <>
- {response.content && ( - Content type: {Object.keys(response.content)[0]} + {!isEmptyResponse && ( + + Content type: {Object.keys(response.content)[0]} + )} <> {examples.length > 1 && ( @@ -70,5 +77,5 @@ const TagsOperationCodeSectionResponsesSample = ({ export default TagsOperationCodeSectionResponsesSample const getLanguageFromMedia = (media: string) => { - return media.substring(media.indexOf("/")) + return media.substring(media.indexOf("/") + 1) } diff --git a/www/apps/api-reference/components/Tags/Operation/CodeSection/Responses/__tests__/index.test.tsx b/www/apps/api-reference/components/Tags/Operation/CodeSection/Responses/__tests__/index.test.tsx new file mode 100644 index 0000000000..2871c332b0 --- /dev/null +++ b/www/apps/api-reference/components/Tags/Operation/CodeSection/Responses/__tests__/index.test.tsx @@ -0,0 +1,89 @@ +import React from "react" +import { beforeEach, describe, expect, test, vi } from "vitest" +import { cleanup, render } from "@testing-library/react" +import { OpenAPI } from "types" + +// mock data +const mockOperation: OpenAPI.Operation = { + operationId: "mockOperation", + summary: "Mock Operation", + description: "Mock Operation", + "x-authenticated": false, + "x-codeSamples": [], + requestBody: { + content: {}, + }, + parameters: [], + responses: { + "200": { + description: "OK", + content: { + "application/json": { + schema: { + type: "object", + properties: { + name: { type: "string", properties: {} }, + }, + }, + }, + } + }, + }, +} + +// mock components +vi.mock("docs-ui", () => ({ + Badge: ({ variant, children }: { variant: string, children: React.ReactNode }) => ( +
{children}
+ ), +})) +vi.mock("@/components/Tags/Operation/CodeSection/Responses/Sample", () => ({ + default: () =>
Sample
, +})) + +import TagsOperationCodeSectionResponses from ".." + +beforeEach(() => { + vi.clearAllMocks() + cleanup() +}) + +describe("rendering", () => { + test("renders response code badge", () => { + const { getByTestId } = render( + + ) + const badgeElement = getByTestId("badge") + expect(badgeElement).toBeInTheDocument() + expect(badgeElement).toHaveTextContent("200") + expect(badgeElement).toHaveAttribute("data-variant", "green") + }) + + test("doesn't render when no response is available", () => { + const mockOperation: OpenAPI.Operation = { + operationId: "mockOperation", + summary: "Mock Operation", + description: "Mock Operation", + "x-authenticated": false, + "x-codeSamples": [], + requestBody: { + content: {}, + }, + parameters: [], + responses: {}, + } + const { container } = render( + + ) + expect(container).toBeEmptyDOMElement() + }) + + test("renders sample component", () => { + const { getByTestId } = render( + + ) + const sampleElement = getByTestId("sample") + expect(sampleElement).toBeInTheDocument() + expect(sampleElement).toHaveTextContent("Sample") + }) +}) \ No newline at end of file diff --git a/www/apps/api-reference/components/Tags/Operation/CodeSection/Responses/index.tsx b/www/apps/api-reference/components/Tags/Operation/CodeSection/Responses/index.tsx index f2f5a9847a..58de9f5a7b 100644 --- a/www/apps/api-reference/components/Tags/Operation/CodeSection/Responses/index.tsx +++ b/www/apps/api-reference/components/Tags/Operation/CodeSection/Responses/index.tsx @@ -1,3 +1,4 @@ +import React from "react" import type { OpenAPI } from "types" import dynamic from "next/dynamic" import type { TagsOperationCodeSectionResponsesSampleProps } from "./Sample" diff --git a/www/apps/api-reference/components/Tags/Operation/CodeSection/__tests__/index.test.tsx b/www/apps/api-reference/components/Tags/Operation/CodeSection/__tests__/index.test.tsx new file mode 100644 index 0000000000..7fbc05dcda --- /dev/null +++ b/www/apps/api-reference/components/Tags/Operation/CodeSection/__tests__/index.test.tsx @@ -0,0 +1,107 @@ +import React from "react" +import { beforeEach, describe, expect, test, vi } from "vitest" +import { cleanup, render, waitFor } from "@testing-library/react" +import { OpenAPI } from "types" + +// mock data +const mockOperation: OpenAPI.Operation = { + operationId: "mockOperation", + summary: "Mock Operation", + description: "Mock Operation", + "x-authenticated": false, + "x-codeSamples": [ + { + label: "Request Sample 1", + lang: "javascript", + source: "console.log('Request Sample 1')", + } + ], + requestBody: { + content: {}, + }, + parameters: [], + responses: { + "200": { + description: "OK", + content: { + "application/json": { + schema: { + type: "object", + properties: { + name: { type: "string", properties: {} }, + }, + }, + }, + }, + }, + }, +} + +// mock components +vi.mock("@/components/MethodLabel", () => ({ + default: ({ method }: { method: string }) => ( +
{method}
+ ), +})) +vi.mock("@/components/Tags/Operation/CodeSection/RequestSamples", () => ({ + default: () =>
Request Samples
, +})) +vi.mock("@/components/Tags/Operation/CodeSection/Responses", () => ({ + default: () =>
Responses
, +})) +vi.mock("docs-ui", () => ({ + CopyButton: ({ text }: { text: string }) => ( +
{text}
+ ), +})) + +import TagsOperationCodeSection from ".." + +beforeEach(() => { + vi.clearAllMocks() + cleanup() +}) + +describe("rendering", () => { + test("renders method label, request samples, and responses", async () => { + const { getByTestId } = render( + + ) + const methodLabelElement = getByTestId("method-label") + expect(methodLabelElement).toBeInTheDocument() + expect(methodLabelElement).toHaveTextContent("GET") + const endpointPathElement = getByTestId("endpoint-path") + expect(endpointPathElement).toBeInTheDocument() + expect(endpointPathElement).toHaveTextContent("/api/v1/users") + const responsesElement = getByTestId("responses") + expect(responsesElement).toBeInTheDocument() + const copyButtonElement = getByTestId("copy-button") + expect(copyButtonElement).toBeInTheDocument() + expect(copyButtonElement).toHaveTextContent("/api/v1/users") + + await waitFor(() => { + const requestSamplesElement = getByTestId("request-samples") + expect(requestSamplesElement).toBeInTheDocument() + }) + }) + + test("doesn't render request samples when no code samples are available", () => { + const modifiedOperation: OpenAPI.Operation = { + ...mockOperation, + "x-codeSamples": [], + } + const { container } = render( + + ) + const requestSamplesElement = container.querySelector("[data-testid='request-samples']") + expect(requestSamplesElement).not.toBeInTheDocument() + }) + + test("renders code section with className", () => { + const { getByTestId } = render( + + ) + const codeSectionElement = getByTestId("code-section") + expect(codeSectionElement).toHaveClass("test-class") + }) +}) \ No newline at end of file diff --git a/www/apps/api-reference/components/Tags/Operation/CodeSection/index.tsx b/www/apps/api-reference/components/Tags/Operation/CodeSection/index.tsx index a4c75eed7c..0c7964a026 100644 --- a/www/apps/api-reference/components/Tags/Operation/CodeSection/index.tsx +++ b/www/apps/api-reference/components/Tags/Operation/CodeSection/index.tsx @@ -1,5 +1,6 @@ "use client" +import React from "react" import MethodLabel from "@/components/MethodLabel" import type { OpenAPI } from "types" import TagsOperationCodeSectionResponses from "./Responses" @@ -27,7 +28,10 @@ const TagOperationCodeSection = ({ className, }: TagOperationCodeSectionProps) => { return ( -
+
- + {endpointPath}
@@ -44,7 +51,7 @@ const TagOperationCodeSection = ({
- {operation["x-codeSamples"] && ( + {operation["x-codeSamples"] && operation["x-codeSamples"].length > 0 && ( diff --git a/www/apps/api-reference/components/Tags/Operation/DescriptionSection/DeprecationNotice/__tests__/index.test.tsx b/www/apps/api-reference/components/Tags/Operation/DescriptionSection/DeprecationNotice/__tests__/index.test.tsx new file mode 100644 index 0000000000..e02c9b4467 --- /dev/null +++ b/www/apps/api-reference/components/Tags/Operation/DescriptionSection/DeprecationNotice/__tests__/index.test.tsx @@ -0,0 +1,54 @@ +import React from "react" +import { beforeEach, describe, expect, test, vi } from "vitest" +import { cleanup, render } from "@testing-library/react" + +// mock data +const mockDeprecationMessage = "This operation is deprecated" + +// mock components +vi.mock("docs-ui", () => ({ + Badge: ({ variant, children }: { variant: string, children: React.ReactNode }) => ( +
{children}
+ ), + Tooltip: ({ text, children }: { text: string, children: React.ReactNode }) => ( +
{children}
+ ), +})) + +import TagsOperationDescriptionSectionDeprecationNotice from ".." + +beforeEach(() => { + vi.clearAllMocks() + cleanup() +}) + +describe("rendering", () => { + test("renders deprecation notice with tooltip", () => { + const { getByTestId } = render( + + ) + const badgeElement = getByTestId("badge") + expect(badgeElement).toBeInTheDocument() + expect(badgeElement).toHaveTextContent("deprecated") + expect(badgeElement).toHaveAttribute("data-variant", "orange") + const tooltipElement = getByTestId("tooltip") + expect(tooltipElement).toBeInTheDocument() + expect(tooltipElement).toHaveAttribute("data-text", mockDeprecationMessage) + }) + + test("doesn't render tooltip when no deprecation message is available", () => { + const { container } = render( + + ) + const tooltipElement = container.querySelector("[data-testid='tooltip']") + expect(tooltipElement).not.toBeInTheDocument() + }) + + test("renders deprecation notice with className", () => { + const { getByTestId } = render( + + ) + const deprecationNoticeElement = getByTestId("deprecation-notice") + expect(deprecationNoticeElement).toHaveClass("test-class") + }) +}) \ No newline at end of file diff --git a/www/apps/api-reference/components/Tags/Operation/DescriptionSection/DeprecationNotice/index.tsx b/www/apps/api-reference/components/Tags/Operation/DescriptionSection/DeprecationNotice/index.tsx index 9f024848e0..aace8cb756 100644 --- a/www/apps/api-reference/components/Tags/Operation/DescriptionSection/DeprecationNotice/index.tsx +++ b/www/apps/api-reference/components/Tags/Operation/DescriptionSection/DeprecationNotice/index.tsx @@ -1,3 +1,4 @@ +import React from "react" import clsx from "clsx" import { Badge, Tooltip } from "docs-ui" @@ -15,7 +16,10 @@ const TagsOperationDescriptionSectionDeprecationNotice = ({ } return ( -
+
{deprecationMessage && ( {getBadge()} )} diff --git a/www/apps/api-reference/components/Tags/Operation/DescriptionSection/Events/__tests__/index.test.tsx b/www/apps/api-reference/components/Tags/Operation/DescriptionSection/Events/__tests__/index.test.tsx new file mode 100644 index 0000000000..9409c1a4b5 --- /dev/null +++ b/www/apps/api-reference/components/Tags/Operation/DescriptionSection/Events/__tests__/index.test.tsx @@ -0,0 +1,246 @@ +import React from "react" +import { beforeEach, describe, expect, test, vi } from "vitest" +import { cleanup, fireEvent, render } from "@testing-library/react" +import { MenuItem, OpenAPI } from "types" + +// mock data +const mockEvents: OpenAPI.OasEvents[] = [ + { + name: "event1", + payload: "payload1", + description: "description1", + }, +] + +// mock functions +const mockParseEventPayload = vi.fn((payload: string) => { + return { + parsed_payload: payload, + payload_for_snippet: payload, + } +}) +const mockHandleCopy = vi.fn() +const mockUseCopied = vi.fn((options: unknown) => ({ + handleCopy: mockHandleCopy, + isCopied: false, +})) +const mockUseGenerateSnippet = vi.fn((options: unknown) => ({ + snippet: "snippet", +})) + +// mock components +vi.mock("docs-ui", () => ({ + Badge: ({ + variant, + children, + ...props + }: { variant: string, children: React.ReactNode } & React.HTMLAttributes) => ( +
{children}
+ ), + DetailsSummary: ({ title, subtitle }: { title: string, subtitle: React.ReactNode }) => ( +
{title}
+ ), + DropdownMenu: ({ + dropdownButtonContent, + menuItems + }: { dropdownButtonContent: React.ReactNode, menuItems: MenuItem[] }) => ( +
+
{dropdownButtonContent}
+
+ {menuItems.map((item, index) => ( +
{ + if ("action" in item) { + item.action() + } + }}> + {"title" in item && item.title} +
+ ))} +
+
+ ), + Link: ({ href, children }: { href: string, children: React.ReactNode }) => ( +
{children}
+ ), + MarkdownContent: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + parseEventPayload: (payload: string) => mockParseEventPayload(payload), + Tabs: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + TabsContent: ({ value, children }: { value: string, children: React.ReactNode }) => ( +
{children}
+ ), + TabsContentWrapper: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + TabsList: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + TabsTrigger: ({ value, children }: { value: string, children: React.ReactNode }) => ( +
{children}
+ ), + Tooltip: ({ + text, + children, + ...props + }: { text: string, children: React.ReactNode } & React.HTMLAttributes) => ( +
{children}
+ ), + useCopy: (options: unknown) => mockUseCopied(options), + useGenerateSnippet: (options: unknown) => mockUseGenerateSnippet(options), +})) +vi.mock("@/components/Tags/Operation/Parameters", () => ({ + default: () =>
Parameters
, +})) +vi.mock("@medusajs/icons", () => ({ + CheckCircle: () =>
CheckCircle
, + SquareTwoStack: () =>
SquareTwoStack
, + Tag: () =>
Tag
, + Brackets: () =>
Brackets
, +})) + +import TagsOperationDescriptionSectionEvents from ".." + +beforeEach(() => { + vi.clearAllMocks() + cleanup() +}) + +describe("rendering", () => { + test("renders events", () => { + const { container } = render( + + ) + const detailsSummaryElement = container.querySelector("[data-testid='details-summary']") + expect(detailsSummaryElement).toBeInTheDocument() + expect(detailsSummaryElement).toHaveAttribute("data-title", "Emitted Events") + + const tabsElement = container.querySelector("[data-testid='tabs']") + expect(tabsElement).toBeInTheDocument() + + const tabsContentElement = tabsElement!.querySelectorAll("[data-testid='tabs-content']") + expect(tabsContentElement).toHaveLength(mockEvents.length) + expect(tabsContentElement[0]).toHaveAttribute("data-value", mockEvents[0].name) + expect(tabsContentElement[0]).toHaveTextContent(mockEvents[0].description!) + + const deprecatedBadge = tabsContentElement[0].querySelector("[data-testid='deprecated-badge']") + expect(deprecatedBadge).not.toBeInTheDocument() + + const sinceBadge = tabsContentElement[0].querySelector("[data-testid='since-badge']") + expect(sinceBadge).not.toBeInTheDocument() + + const parameters = tabsContentElement[0].querySelector("[data-testid='parameters']") + expect(parameters).toBeInTheDocument() + }) + + test("renders deprecated badge without tooltip when event is deprecated and does not have a deprecated message", () => { + const modifiedEvents: OpenAPI.OasEvents[] = [ + { + ...mockEvents[0], + deprecated: true, + }, + ] + const { container } = render( + + ) + const deprecatedBadge = container.querySelector("[data-testid='deprecated-badge']") + expect(deprecatedBadge).toBeInTheDocument() + expect(deprecatedBadge).toHaveAttribute("data-variant", "orange") + expect(deprecatedBadge).toHaveTextContent("Deprecated") + const deprecatedTooltip = container.querySelector("[data-testid='deprecated-tooltip']") + expect(deprecatedTooltip).not.toBeInTheDocument() + }) + + test("renders deprecated badge with tooltip when event is deprecated and has a deprecated message", () => { + const modifiedEvents: OpenAPI.OasEvents[] = [ + { + ...mockEvents[0], + deprecated: true, + deprecated_message: "This event is deprecated", + }, + ] + const { container } = render( + + ) + const deprecatedBadge = container.querySelector("[data-testid='deprecated-badge']") + expect(deprecatedBadge).toBeInTheDocument() + expect(deprecatedBadge).toHaveAttribute("data-variant", "orange") + expect(deprecatedBadge).toHaveTextContent("Deprecated") + const deprecatedTooltip = container.querySelector("[data-testid='deprecated-tooltip']") + expect(deprecatedTooltip).toBeInTheDocument() + expect(deprecatedTooltip).toHaveAttribute("data-text", "This event is deprecated") + }) + + test("renders since badge when event has a since version", () => { + const modifiedEvents: OpenAPI.OasEvents[] = [ + { + ...mockEvents[0], + since: "1.0.0", + }, + ] + const { container } = render( + + ) + const sinceBadge = container.querySelector("[data-testid='since-badge']") + expect(sinceBadge).toBeInTheDocument() + expect(sinceBadge).toHaveAttribute("data-variant", "blue") + expect(sinceBadge).toHaveTextContent("v1.0.0") + const sinceTooltip = container.querySelector("[data-testid='since-tooltip']") + expect(sinceTooltip).toBeInTheDocument() + expect(sinceTooltip).toHaveAttribute("data-text", "This event is emitted since v1.0.0") + }) + + test("renders SquareTwoStack icon when event name and snippet are not copied", () => { + mockUseCopied.mockReturnValue({ + handleCopy: mockHandleCopy, + isCopied: false, + }) + const { container } = render( + + ) + const squareTwoStackIcon = container.querySelector("[data-testid='square-two-stack']") + expect(squareTwoStackIcon).toBeInTheDocument() + const checkCircleIcon = container.querySelector("[data-testid='check-circle']") + expect(checkCircleIcon).not.toBeInTheDocument() + }) + + test("renders CheckCircle icon when event name and snippet are copied", () => { + mockUseCopied.mockReturnValue({ + handleCopy: mockHandleCopy, + isCopied: true, + }) + const { container } = render( + + ) + const checkCircleIcon = container.querySelector("[data-testid='check-circle']") + expect(checkCircleIcon).toBeInTheDocument() + const squareTwoStackIcon = container.querySelector("[data-testid='square-two-stack']") + expect(squareTwoStackIcon).not.toBeInTheDocument() + }) +}) + +describe("interactions", () => { + test("copies event name when copy button is clicked", () => { + const { container } = render( + + ) + expect(mockUseCopied).toHaveBeenCalledWith(mockEvents[0].name) + const dropdownMenuItems = container.querySelectorAll("[data-testid='dropdown-menu-item']") + expect(dropdownMenuItems).toHaveLength(2) + fireEvent.click(dropdownMenuItems[0]!) + expect(mockHandleCopy).toHaveBeenCalledTimes(1) + }) + + test("copies subscriber for event when copy button is clicked", () => { + const { container } = render( + + ) + expect(mockUseCopied).toHaveBeenCalledWith("snippet") + const dropdownMenuItems = container.querySelectorAll("[data-testid='dropdown-menu-item']") + expect(dropdownMenuItems).toHaveLength(2) + fireEvent.click(dropdownMenuItems[1]!) + expect(mockHandleCopy).toHaveBeenCalledTimes(1) + }) +}) \ No newline at end of file diff --git a/www/apps/api-reference/components/Tags/Operation/DescriptionSection/Events/index.tsx b/www/apps/api-reference/components/Tags/Operation/DescriptionSection/Events/index.tsx index c1cced590c..1788227e5e 100644 --- a/www/apps/api-reference/components/Tags/Operation/DescriptionSection/Events/index.tsx +++ b/www/apps/api-reference/components/Tags/Operation/DescriptionSection/Events/index.tsx @@ -1,5 +1,6 @@ "use client" +import React from "react" import { Badge, DetailsSummary, @@ -105,15 +106,27 @@ const TagsOperationDescriptionSectionEvent = ({ {event.deprecated && (event.deprecated_message ? ( - - Deprecated + + + Deprecated + ) : ( - Deprecated + + Deprecated + ))} {event.since && ( - - v{event.since} + + + v{event.since} + )}
diff --git a/www/apps/api-reference/components/Tags/Operation/DescriptionSection/Parameters/__tests__/index.test.tsx b/www/apps/api-reference/components/Tags/Operation/DescriptionSection/Parameters/__tests__/index.test.tsx new file mode 100644 index 0000000000..1c4ffe1f37 --- /dev/null +++ b/www/apps/api-reference/components/Tags/Operation/DescriptionSection/Parameters/__tests__/index.test.tsx @@ -0,0 +1,135 @@ +import React from "react" +import { beforeEach, describe, expect, test, vi } from "vitest" +import { cleanup, render } from "@testing-library/react" +import { OpenAPI } from "types" +import { TagOperationParametersProps } from "../../../Parameters" + +// mock data +const mockParameters: OpenAPI.Parameter[] = [ + { + name: "parameter1", + in: "query", + description: "description1", + schema: { + type: "string", + properties: {}, + } as OpenAPI.SchemaObject, + examples: {}, + }, +] + +// mock components +vi.mock("@/components/Tags/Operation/Parameters", () => ({ + default: (props: TagOperationParametersProps) => ( +
+ {Object.values(props.schemaObject.properties).map((property) => ( +
+ {property.parameterName} +
+ ))} +
+ ), +})) + +import TagsOperationDescriptionSectionParameters from ".." + +beforeEach(() => { + vi.clearAllMocks() + cleanup() +}) + +describe("rendering", () => { + test("renders header parameters when parameters have header in parameter location", () => { + const modifiedParameters: OpenAPI.Parameter[] = [ + { + ...mockParameters[0], + in: "header", + }, + ] + const { container } = render( + + ) + + const headerParameters = container.querySelector("[data-testid='header-parameters']") + expect(headerParameters).toBeInTheDocument() + const properties = headerParameters!.querySelectorAll("[data-testid='property']") + expect(properties).toHaveLength(1) + expect(properties[0]).toHaveTextContent(mockParameters[0].name) + }) + + test("does not render header parameters when parameters do not have header in parameter location", () => { + const modifiedParameters: OpenAPI.Parameter[] = [ + { + ...mockParameters[0], + in: "query", + }, + ] + const { container } = render( + + ) + const headerParameters = container.querySelector("[data-testid='header-parameters']") + expect(headerParameters).not.toBeInTheDocument() + }) + + test("renders path parameters when parameters have path in parameter location", () => { + const modifiedParameters: OpenAPI.Parameter[] = [ + { + ...mockParameters[0], + in: "path", + }, + ] + const { container } = render( + + ) + const pathParameters = container.querySelector("[data-testid='path-parameters']") + expect(pathParameters).toBeInTheDocument() + const properties = pathParameters!.querySelectorAll("[data-testid='property']") + expect(properties).toHaveLength(1) + expect(properties[0]).toHaveTextContent(mockParameters[0].name) + }) + + test("does not render path parameters when parameters do not have path in parameter location", () => { + const modifiedParameters: OpenAPI.Parameter[] = [ + { + ...mockParameters[0], + in: "query", + }, + ] + const { container } = render( + + ) + const pathParameters = container.querySelector("[data-testid='path-parameters']") + expect(pathParameters).not.toBeInTheDocument() + }) + + test("renders query parameters when parameters have query in parameter location", () => { + const modifiedParameters: OpenAPI.Parameter[] = [ + { + ...mockParameters[0], + in: "query", + }, + ] + const { container } = render( + + ) + const queryParameters = container.querySelector("[data-testid='query-parameters']") + expect(queryParameters).toBeInTheDocument() + const properties = queryParameters!.querySelectorAll("[data-testid='property']") + expect(properties).toHaveLength(1) + expect(properties[0]).toHaveTextContent(mockParameters[0].name) + }) + + test("does not render query parameters when parameters do not have query in parameter location", () => { + const modifiedParameters: OpenAPI.Parameter[] = [ + { + ...mockParameters[0], + in: "header", + }, + ] + const { container } = render( + + ) + const queryParameters = container.querySelector("[data-testid='query-parameters']") + expect(queryParameters).not.toBeInTheDocument() + }) +}) \ No newline at end of file diff --git a/www/apps/api-reference/components/Tags/Operation/DescriptionSection/Parameters/index.tsx b/www/apps/api-reference/components/Tags/Operation/DescriptionSection/Parameters/index.tsx index e7aae8126b..6e7daf4244 100644 --- a/www/apps/api-reference/components/Tags/Operation/DescriptionSection/Parameters/index.tsx +++ b/www/apps/api-reference/components/Tags/Operation/DescriptionSection/Parameters/index.tsx @@ -1,3 +1,4 @@ +import React from "react" import type { OpenAPI } from "types" import TagOperationParameters from "../../Parameters" @@ -60,6 +61,7 @@ const TagsOperationDescriptionSectionParameters = ({ )} @@ -71,6 +73,7 @@ const TagsOperationDescriptionSectionParameters = ({ )} @@ -82,6 +85,7 @@ const TagsOperationDescriptionSectionParameters = ({ )} diff --git a/www/apps/api-reference/components/Tags/Operation/DescriptionSection/RequestBody/index.tsx b/www/apps/api-reference/components/Tags/Operation/DescriptionSection/RequestBody/index.tsx index 10dacd2bb8..e5ba6765d6 100644 --- a/www/apps/api-reference/components/Tags/Operation/DescriptionSection/RequestBody/index.tsx +++ b/www/apps/api-reference/components/Tags/Operation/DescriptionSection/RequestBody/index.tsx @@ -1,3 +1,4 @@ +import React from "react" import type { OpenAPI } from "types" import TagOperationParameters from "../../Parameters" import { DetailsSummary } from "docs-ui" diff --git a/www/apps/api-reference/components/Tags/Operation/DescriptionSection/Responses/__tests__/index.test.tsx b/www/apps/api-reference/components/Tags/Operation/DescriptionSection/Responses/__tests__/index.test.tsx new file mode 100644 index 0000000000..28a658206b --- /dev/null +++ b/www/apps/api-reference/components/Tags/Operation/DescriptionSection/Responses/__tests__/index.test.tsx @@ -0,0 +1,150 @@ +import React from "react" +import { beforeEach, describe, expect, test, vi } from "vitest" +import { cleanup, render } from "@testing-library/react" +import { OpenAPI } from "types" +import { TagOperationParametersProps } from "../../../Parameters" + +// mock data +const mockResponses: OpenAPI.ResponsesObject = { + "200": { + description: "success", + content: { + "application/json": { + schema: { + type: "object", + properties: { + name: { type: "string", properties: {} }, + }, + }, + }, + } + }, +} + +// mock components +vi.mock("@/components/Tags/Operation/Parameters", () => ({ + default: (props: TagOperationParametersProps) => ( +
+ {Object.entries(props.schemaObject.properties).map(([key, property]) => ( +
+ {key} +
+ ))} +
+ ), +})) +vi.mock("docs-ui", () => ({ + Badge: ({ variant, children }: { variant: string, children: React.ReactNode }) => ( +
{children}
+ ), + DetailsSummary: ({ + title, + badge, + ...props }: { title: string, [key: string]: any }) => ( +
+ {title} + {badge} +
+ ), + Details: ({ + children, + summaryElm, + ...props + }: { children: React.ReactNode, [key: string]: any }) => ( +
{summaryElm}{children}
+ ), +})) + +import TagsOperationDescriptionSectionResponses from ".." + +beforeEach(() => { + vi.clearAllMocks() + cleanup() +}) + +describe("rendering", () => { + test("renders success response with content", () => { + const { container } = render( + + ) + const successResponse = container.querySelector("[data-testid='response-success']") + expect(successResponse).toBeInTheDocument() + const successResponseParameters = container.querySelector("[data-testid='response-success-parameters']") + expect(successResponseParameters).toBeInTheDocument() + const successResponseParametersProperties = successResponseParameters!.querySelectorAll("[data-testid='property']") + expect(successResponseParametersProperties).toHaveLength(1) + expect(successResponseParametersProperties[0]).toHaveTextContent("name") + const successResponseBadge = container.querySelector("[data-testid='badge']") + expect(successResponseBadge).toBeInTheDocument() + expect(successResponseBadge).toHaveTextContent("Success") + expect(successResponseBadge).toHaveAttribute("data-variant", "green") + }) + + test("renders error response with content", () => { + const modifiedResponses: OpenAPI.ResponsesObject = { + "400": { + description: "error", + content: { + "application/json": { + schema: { + type: "object", + properties: { + name: { type: "string", properties: {} }, + }, + }, + }, + }, + }, + } + const { container } = render( + + ) + const errorResponse = container.querySelector("[data-testid='response-error']") + expect(errorResponse).toBeInTheDocument() + const errorResponseParameters = container.querySelector("[data-testid='response-error-parameters']") + expect(errorResponseParameters).toBeInTheDocument() + const errorResponseParametersProperties = errorResponseParameters!.querySelectorAll("[data-testid='property']") + expect(errorResponseParametersProperties).toHaveLength(1) + expect(errorResponseParametersProperties[0]).toHaveTextContent("name") + const errorResponseBadge = container.querySelector("[data-testid='badge']") + expect(errorResponseBadge).toBeInTheDocument() + expect(errorResponseBadge).toHaveTextContent("Error") + expect(errorResponseBadge).toHaveAttribute("data-variant", "red") + }) + + test("renders empty success response", () => { + const modifiedResponses: OpenAPI.ResponsesObject = { + "204": { + description: "empty", + content: {}, + }, + } + const { container } = render( + + ) + const emptyResponse = container.querySelector("[data-testid='response-empty']") + expect(emptyResponse).toBeInTheDocument() + const emptyResponseBadge = container.querySelector("[data-testid='badge']") + expect(emptyResponseBadge).toBeInTheDocument() + expect(emptyResponseBadge).toHaveTextContent("Success") + expect(emptyResponseBadge).toHaveAttribute("data-variant", "green") + }) + + test("renders empty error response", () => { + const modifiedResponses: OpenAPI.ResponsesObject = { + "400": { + description: "empty", + content: {}, + }, + } + const { container } = render( + + ) + const emptyResponse = container.querySelector("[data-testid='response-empty']") + expect(emptyResponse).toBeInTheDocument() + const emptyResponseBadge = container.querySelector("[data-testid='badge']") + expect(emptyResponseBadge).toBeInTheDocument() + expect(emptyResponseBadge).toHaveTextContent("Error") + expect(emptyResponseBadge).toHaveAttribute("data-variant", "red") + }) +}) \ No newline at end of file diff --git a/www/apps/api-reference/components/Tags/Operation/DescriptionSection/Responses/index.tsx b/www/apps/api-reference/components/Tags/Operation/DescriptionSection/Responses/index.tsx index 61aa2d41c5..52814ce288 100644 --- a/www/apps/api-reference/components/Tags/Operation/DescriptionSection/Responses/index.tsx +++ b/www/apps/api-reference/components/Tags/Operation/DescriptionSection/Responses/index.tsx @@ -1,3 +1,4 @@ +import React from "react" import type { OpenAPI } from "types" import clsx from "clsx" import TagOperationParameters from "../../Parameters" @@ -19,9 +20,12 @@ const TagsOperationDescriptionSectionResponses = ({ > {Object.entries(responses).map(([code, response], index) => { const successCode = code.startsWith("20") + const isEmptyResponse = + response?.content === undefined || + Object.keys(response.content).length === 0 return ( - {response.content && ( + {!isEmptyResponse && ( <> {successCode && ( <> @@ -34,6 +38,7 @@ const TagsOperationDescriptionSectionResponses = ({ index !== 0 && "border-t-0", index === 0 && "border-b-0" )} + data-testid="response-success" /> )} @@ -56,6 +62,7 @@ const TagsOperationDescriptionSectionResponses = ({ } openInitial={index === 0} className={clsx(index > 1 && "border-t-0")} + data-testid="response-error" > )} )} - {!response.content && ( + {isEmptyResponse && ( 1 && "border-b-0" )} + data-testid="response-empty" /> )} diff --git a/www/apps/api-reference/components/Tags/Operation/DescriptionSection/Security/__tests__/index.test.tsx b/www/apps/api-reference/components/Tags/Operation/DescriptionSection/Security/__tests__/index.test.tsx new file mode 100644 index 0000000000..4cc942ab8a --- /dev/null +++ b/www/apps/api-reference/components/Tags/Operation/DescriptionSection/Security/__tests__/index.test.tsx @@ -0,0 +1,73 @@ +import React from "react" +import { beforeEach, describe, expect, test, vi } from "vitest" +import { cleanup, fireEvent, render } from "@testing-library/react" +import { OpenAPI } from "types" + +// mock function +const mockGetSecuritySchema = vi.fn((key: string) => ({ + "x-displayName": "Bearer Token", + "x-is-auth": true, +})) +const mockUseBaseSpecs = vi.fn(() => ({ + getSecuritySchema: mockGetSecuritySchema, +})) + +// mock components and hooks +vi.mock("@/providers/base-specs", () => ({ + useBaseSpecs: () => ({ + getSecuritySchema: mockGetSecuritySchema, + }), +})) +vi.mock("docs-ui", () => ({ + Card: ({ title, text, href }: { title: string, text: string, href: string }) => ( +
+ {text} +
+ ), +})) + +import TagsOperationDescriptionSectionSecurity from ".." + +beforeEach(() => { + vi.clearAllMocks() + cleanup() +}) + +describe("rendering", () => { + test("renders security with authentication", () => { + const { container } = render( + + ) + + const cardElement = container.querySelector("[data-testid='card']") + expect(cardElement).toBeInTheDocument() + expect(cardElement).toHaveTextContent("Bearer Token") + expect(cardElement).toHaveAttribute("data-href", "#authentication") + }) + + test("renders security without authentication", () => { + mockGetSecuritySchema.mockReturnValue({ + "x-displayName": "Bearer Token", + "x-is-auth": false, + }) + const { container } = render( + + ) + + const cardElement = container.querySelector("[data-testid='card']") + expect(cardElement).toBeInTheDocument() + expect(cardElement).toHaveTextContent("Bearer Token") + expect(cardElement).not.toHaveAttribute("data-href") + }) + + test("renders security with multiple security schemes", () => { + const { container } = render( + + ) + + const cardElement = container.querySelector("[data-testid='card']") + expect(cardElement).toBeInTheDocument() + // it renders the same security scheme twice because we're mocking the getSecuritySchema function + expect(cardElement).toHaveTextContent("Bearer Token or Bearer Token") + }) +}) \ No newline at end of file diff --git a/www/apps/api-reference/components/Tags/Operation/DescriptionSection/Security/index.tsx b/www/apps/api-reference/components/Tags/Operation/DescriptionSection/Security/index.tsx index 14812636cf..6dcfdf0fa2 100644 --- a/www/apps/api-reference/components/Tags/Operation/DescriptionSection/Security/index.tsx +++ b/www/apps/api-reference/components/Tags/Operation/DescriptionSection/Security/index.tsx @@ -1,3 +1,4 @@ +import React from "react" import { useBaseSpecs } from "@/providers/base-specs" import type { OpenAPI } from "types" import { Card } from "docs-ui" diff --git a/www/apps/api-reference/components/Tags/Operation/DescriptionSection/WorkflowBadge/__tests__/index.test.tsx b/www/apps/api-reference/components/Tags/Operation/DescriptionSection/WorkflowBadge/__tests__/index.test.tsx new file mode 100644 index 0000000000..5dbc71ef79 --- /dev/null +++ b/www/apps/api-reference/components/Tags/Operation/DescriptionSection/WorkflowBadge/__tests__/index.test.tsx @@ -0,0 +1,51 @@ +import React from "react" +import { beforeEach, describe, expect, test, vi } from "vitest" +import { cleanup, fireEvent, render } from "@testing-library/react" +import { OpenAPI } from "types" + +// mock data +const mockWorkflow = "test-workflow" + +// mock components +vi.mock("@/config", () => ({ + config: { + baseUrl: "https://example.com", + }, +})) +vi.mock("docs-ui", () => ({ + SourceCodeLink: ({ link, text, icon }: { link: string, text: string, icon: React.ReactNode }) => ( +
+ {text} {icon} +
+ ), + DecisionProcessIcon: () => ( +
+ Icon +
+ ), +})) + +import TagsOperationDescriptionSectionWorkflowBadge from ".." + +beforeEach(() => { + vi.clearAllMocks() + cleanup() +}) + +describe("rendering", () => { + test("renders workflow badge", () => { + const { container } = render( + + ) + + const sourceCodeLinkElement = container.querySelector("[data-testid='source-code-link']") + expect(sourceCodeLinkElement).toBeInTheDocument() + expect(sourceCodeLinkElement).toHaveTextContent(mockWorkflow) + expect(sourceCodeLinkElement).toHaveAttribute( + "data-link", + `https://example.com/resources/references/medusa-workflows/${mockWorkflow}` + ) + const decisionProcessIconElement = container.querySelector("[data-testid='decision-process-icon']") + expect(decisionProcessIconElement).toBeInTheDocument() + }) +}) \ No newline at end of file diff --git a/www/apps/api-reference/components/Tags/Operation/DescriptionSection/WorkflowBadge/index.tsx b/www/apps/api-reference/components/Tags/Operation/DescriptionSection/WorkflowBadge/index.tsx index 8e712aaec2..10efe850b4 100644 --- a/www/apps/api-reference/components/Tags/Operation/DescriptionSection/WorkflowBadge/index.tsx +++ b/www/apps/api-reference/components/Tags/Operation/DescriptionSection/WorkflowBadge/index.tsx @@ -1,5 +1,6 @@ +import React from "react" import { DecisionProcessIcon, SourceCodeLink } from "docs-ui" -import { config } from "../../../../../config" +import { config } from "@/config" export type TagsOperationDescriptionSectionWorkflowBadgeProps = { workflow: string diff --git a/www/apps/api-reference/components/Tags/Operation/DescriptionSection/__tests__/index.test.tsx b/www/apps/api-reference/components/Tags/Operation/DescriptionSection/__tests__/index.test.tsx new file mode 100644 index 0000000000..f0be924026 --- /dev/null +++ b/www/apps/api-reference/components/Tags/Operation/DescriptionSection/__tests__/index.test.tsx @@ -0,0 +1,339 @@ +import React from "react" +import { beforeEach, describe, expect, test, vi } from "vitest" +import { cleanup, fireEvent, render, waitFor } from "@testing-library/react" +import { OpenAPI } from "types" + +// mock data +const mockOperation: OpenAPI.Operation = { + operationId: "test-operation-id", + "x-authenticated": false, + "x-codeSamples": [], + tags: ["test-tag"], + summary: "test-summary", + description: "test-description", + responses: {}, + parameters: [], + requestBody: { + content: {}, + }, +} + +// mock components +vi.mock("@/components/Tags/Operation/DescriptionSection/Security", () => ({ + default: ({ security }: { security: OpenAPI.OpenAPIV3.SecurityRequirementObject[] }) => ( +
{JSON.stringify(security)}
+ ), +})) +vi.mock("@/components/Tags/Operation/DescriptionSection/RequestBody", () => ({ + default: ({ requestBody }: { requestBody: OpenAPI.RequestObject }) => ( +
{JSON.stringify(requestBody)}
+ ), +})) +vi.mock("@/components/Tags/Operation/DescriptionSection/Responses", () => ({ + default: ({ responses }: { responses: OpenAPI.ResponsesObject }) => ( +
{JSON.stringify(responses)}
+ ), +})) +vi.mock("@/components/Tags/Operation/DescriptionSection/Parameters", () => ({ + default: ({ parameters }: { parameters: OpenAPI.Parameter[] }) => ( +
{JSON.stringify(parameters)}
+ ), +})) +vi.mock("@/components/Tags/Operation/DescriptionSection/WorkflowBadge", () => ({ + default: ({ workflow }: { workflow: string }) => ( +
{workflow}
+ ), +})) +vi.mock("@/components/Tags/Operation/DescriptionSection/Events", () => ({ + default: ({ events }: { events: OpenAPI.OasEvents[] }) => ( +
{JSON.stringify(events)}
+ ), +})) +vi.mock("@/components/Tags/Operation/DescriptionSection/DeprecationNotice", () => ({ + default: ({ deprecationMessage }: { deprecationMessage: string }) => ( +
{deprecationMessage}
+ ), +})) +vi.mock("@/components/Tags/Operation/DescriptionSection/FeatureFlagNotice", () => ({ + default: ({ featureFlag }: { featureFlag: string }) => ( +
{featureFlag}
+ ), +})) +vi.mock("@/components/MDXContent/Client", () => ({ + default: ({ content }: { content: string }) => ( +
{content}
+ ), +})) +vi.mock("docs-ui", () => ({ + Badge: ({ + variant, + children, + ...props + }: { variant: string, children: React.ReactNode, [key: string]: unknown }) => ( +
{children}
+ ), + Link: ({ + href, + children, + ...props + }: { href: string, children: React.ReactNode, [key: string]: unknown }) => ( +
{children}
+ ), + FeatureFlagNotice: ({ featureFlag }: { featureFlag: string }) => ( +
{featureFlag}
+ ), + H2: ({ children }: { children: React.ReactNode }) => ( +

{children}

+ ), + Tooltip: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + MarkdownContent: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), +})) +vi.mock("@/components/Feedback", () => ({ + Feedback: ({ question }: { question: string }) => ( +
{question}
+ ), +})) + +import TagsOperationDescriptionSection from ".." + +beforeEach(() => { + vi.clearAllMocks() + cleanup() +}) + +describe("rendering", () => { + test("renders operation summary, description, feedback, responses (default)", async () => { + const { container } = render() + + const h2Element = container.querySelector("[data-testid='h2']") + expect(h2Element).toBeInTheDocument() + expect(h2Element).toHaveTextContent(mockOperation.summary) + const mdxContentElement = container.querySelector("[data-testid='mdx-content']") + expect(mdxContentElement).toBeInTheDocument() + expect(mdxContentElement).toHaveTextContent(mockOperation.description) + const feedbackElement = container.querySelector("[data-testid='feedback']") + expect(feedbackElement).toBeInTheDocument() + expect(feedbackElement).toHaveTextContent("Did this API Route run successfully?") + await waitFor(() => { + const responsesElement = container.querySelector("[data-testid='responses']") + expect(responsesElement).toBeInTheDocument() + expect(responsesElement).toHaveTextContent(JSON.stringify(mockOperation.responses)) + }) + }) + + test("renders deprecated notice when operation is deprecated", async () => { + const modifiedOperation: OpenAPI.Operation = { + ...mockOperation, + deprecated: true, + "x-deprecated_message": "test-deprecated-message", + } + const { container } = render() + await waitFor(() => { + const deprecationNoticeElement = container.querySelector("[data-testid='deprecation-notice']") + expect(deprecationNoticeElement).toBeInTheDocument() + expect(deprecationNoticeElement).toHaveTextContent("test-deprecated-message") + }) + }) + + test("does not render deprecated notice when operation is not deprecated", () => { + const { container } = render() + const deprecationNoticeElement = container.querySelector("[data-testid='deprecation-notice']") + expect(deprecationNoticeElement).not.toBeInTheDocument() + }) + + test("renders feature flag notice when operation has a feature flag", () => { + const modifiedOperation: OpenAPI.Operation = { + ...mockOperation, + "x-featureFlag": "test-feature-flag", + } + const { container } = render() + const featureFlagNoticeElement = container.querySelector("[data-testid='feature-flag']") + expect(featureFlagNoticeElement).toBeInTheDocument() + expect(featureFlagNoticeElement).toHaveTextContent("test-feature-flag") + }) + + test("does not render feature flag notice when operation does not have a feature flag", () => { + const { container } = render() + const featureFlagNoticeElement = container.querySelector("[data-testid='feature-flag']") + expect(featureFlagNoticeElement).not.toBeInTheDocument() + }) + + test("renders since badge when operation has a since", () => { + const modifiedOperation: OpenAPI.Operation = { + ...mockOperation, + "x-since": "1.0.0", + } + const { container } = render() + const sinceBadgeElement = container.querySelector("[data-testid='since-badge']") + expect(sinceBadgeElement).toBeInTheDocument() + expect(sinceBadgeElement).toHaveTextContent("1.0.0") + }) + + test("does not render since badge when operation does not have a since", () => { + const { container } = render() + const sinceBadgeElement = container.querySelector("[data-testid='since-badge']") + expect(sinceBadgeElement).not.toBeInTheDocument() + }) + + test("renders operation custom badges when operation has custom badges", () => { + const modifiedOperation: OpenAPI.Operation = { + ...mockOperation, + "x-badges": [{ + text: "test-badge", + description: "test-description", + variant: "blue", + }], + } + const { container } = render() + const customBadgeElement = container.querySelector("[data-testid='custom-badge']") + expect(customBadgeElement).toBeInTheDocument() + expect(customBadgeElement).toHaveTextContent("test-badge") + expect(customBadgeElement).toHaveAttribute("data-variant", "blue") + }) + + test("does not render custom badges when operation does not have custom badges", () => { + const { container } = render() + const customBadgeElement = container.querySelector("[data-testid='custom-badge']") + expect(customBadgeElement).not.toBeInTheDocument() + }) + + test("renders operation's workflow badge when operation has a workflow", async () => { + const modifiedOperation: OpenAPI.Operation = { + ...mockOperation, + "x-workflow": "test-workflow", + } + const { container } = render() + await waitFor(() => { + const workflowBadgeElement = container.querySelector("[data-testid='workflow']") + expect(workflowBadgeElement).toBeInTheDocument() + expect(workflowBadgeElement).toHaveTextContent("test-workflow") + }) + }) + + test("does not render workflow badge when operation does not have a workflow", () => { + const { container } = render() + const workflowBadgeElement = container.querySelector("[data-testid='workflow']") + expect(workflowBadgeElement).not.toBeInTheDocument() + }) + + test("renders operation's related guide link when operation has a related guide", () => { + const modifiedOperation: OpenAPI.Operation = { + ...mockOperation, + externalDocs: { + url: "https://example.com", + description: "test-description", + }, + } + const { container } = render() + const relatedGuideLinkElement = container.querySelector("[data-testid='related-guide-link']") + expect(relatedGuideLinkElement).toBeInTheDocument() + expect(relatedGuideLinkElement).toHaveTextContent("test-description") + expect(relatedGuideLinkElement).toHaveAttribute("data-href", "https://example.com") + }) + + test("does not render related guide link when operation does not have a related guide", () => { + const { container } = render() + const relatedGuideLinkElement = container.querySelector("[data-testid='related-guide-link']") + expect(relatedGuideLinkElement).not.toBeInTheDocument() + }) + + test("renders operation's security when operation has security", async () => { + const modifiedOperation: OpenAPI.Operation = { + ...mockOperation, + security: [{ "bearer": [] }], + } + const { container } = render() + await waitFor(() => { + const securityElement = container.querySelector("[data-testid='security']") + expect(securityElement).toBeInTheDocument() + expect(securityElement).toHaveTextContent(JSON.stringify(modifiedOperation.security)) + }) + }) + + test("does not render security when operation does not have security", () => { + const { container } = render() + const securityElement = container.querySelector("[data-testid='security']") + expect(securityElement).not.toBeInTheDocument() + }) + + test("renders operation's parameters when operation has parameters", () => { + const modifiedOperation: OpenAPI.Operation = { + ...mockOperation, + parameters: [{ + name: "test-parameter", + in: "query", + description: "test-description", + schema: { + type: "string", + properties: {}, + }, + examples: {}, + }], + } + const { container } = render() + const parametersElement = container.querySelector("[data-testid='parameters']") + expect(parametersElement).toBeInTheDocument() + expect(parametersElement).toHaveTextContent(JSON.stringify(modifiedOperation.parameters)) + }) + + test("does not render parameters when operation does not have parameters", () => { + const { container } = render() + const parametersElement = container.querySelector("[data-testid='parameters']") + expect(parametersElement).not.toBeInTheDocument() + }) + + test("renders operation's request body when operation has a request body", async () => { + const modifiedOperation: OpenAPI.Operation = { + ...mockOperation, + requestBody: { + content: { + "application/json": { + schema: { + type: "object", + properties: {}, + }, + }, + }, + }, + } + const { container } = render() + await waitFor(() => { + const requestBodyElement = container.querySelector("[data-testid='request-body']") + expect(requestBodyElement).toBeInTheDocument() + expect(requestBodyElement).toHaveTextContent(JSON.stringify(modifiedOperation.requestBody)) + }) + }) + + test("does not render request body when operation does not have a request body", () => { + const { container } = render() + const requestBodyElement = container.querySelector("[data-testid='request-body']") + expect(requestBodyElement).not.toBeInTheDocument() + }) + + test("renders operation's events when operation has events", async () => { + const modifiedOperation: OpenAPI.Operation = { + ...mockOperation, + "x-events": [{ + name: "test-event", + description: "test-description", + payload: "test-payload", + }], + } + const { container } = render() + await waitFor(() => { + const eventsElement = container.querySelector("[data-testid='events']") + expect(eventsElement).toBeInTheDocument() + expect(eventsElement).toHaveTextContent(JSON.stringify(modifiedOperation["x-events"])) + }) + }) + + test("does not render events when operation does not have events", () => { + const { container } = render() + const eventsElement = container.querySelector("[data-testid='events']") + expect(eventsElement).not.toBeInTheDocument() + }) +}) \ No newline at end of file diff --git a/www/apps/api-reference/components/Tags/Operation/DescriptionSection/index.tsx b/www/apps/api-reference/components/Tags/Operation/DescriptionSection/index.tsx index c18fb8ff78..0c87c36e3e 100644 --- a/www/apps/api-reference/components/Tags/Operation/DescriptionSection/index.tsx +++ b/www/apps/api-reference/components/Tags/Operation/DescriptionSection/index.tsx @@ -1,5 +1,6 @@ "use client" +import React from "react" import type { OpenAPI } from "types" import type { TagsOperationDescriptionSectionSecurityProps } from "./Security" import type { TagsOperationDescriptionSectionRequestProps } from "./RequestBody" @@ -77,7 +78,7 @@ const TagsOperationDescriptionSection = ({ - + v{operation["x-since"]} @@ -95,7 +96,11 @@ const TagsOperationDescriptionSection = ({ } clickable={true} > - + {badge.text} @@ -116,6 +121,7 @@ const TagsOperationDescriptionSection = ({ href={operation.externalDocs.url} target="_blank" variant="content" + data-testid="related-guide-link" > {operation.externalDocs.description || "Read More"} @@ -133,16 +139,17 @@ const TagsOperationDescriptionSection = ({ security={operation.security} /> )} - {operation.parameters && ( + {operation.parameters && operation.parameters.length > 0 && ( )} - {operation.requestBody && ( - - )} + {operation.requestBody?.content !== undefined && + Object.keys(operation.requestBody.content).length > 0 && ( + + )} diff --git a/www/apps/api-reference/components/Tags/Operation/FeatureFlagNotice/__tests__/index.test.tsx b/www/apps/api-reference/components/Tags/Operation/FeatureFlagNotice/__tests__/index.test.tsx new file mode 100644 index 0000000000..d3d9203c2b --- /dev/null +++ b/www/apps/api-reference/components/Tags/Operation/FeatureFlagNotice/__tests__/index.test.tsx @@ -0,0 +1,69 @@ +import React from "react" +import { cleanup, render } from "@testing-library/react" +import { beforeEach, describe, expect, test, vi } from "vitest" + +// mock components +vi.mock("docs-ui", () => ({ + Badge: ({ children, className }: BadgeProps) => ( +
{children}
+ ), + Link: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + Tooltip: ({ tooltipChildren, children }: TooltipProps) => ( +
+
{tooltipChildren}
+
{children}
+
+ ), +})) + +import TagsOperationFeatureFlagNotice from ".." +import { BadgeProps, TooltipProps } from "docs-ui" + +beforeEach(() => { + vi.clearAllMocks() + cleanup() +}) + +describe("rendering", () => { + test("renders feature flag notice for endpoint type by default", () => { + const { container } = render() + const tooltipElement = container.querySelector("[data-testid='tooltip']") + expect(tooltipElement).toBeInTheDocument() + const tooltipChildrenElement = container.querySelector("[data-testid='tooltip-children']") + expect(tooltipChildrenElement).toBeInTheDocument() + expect(tooltipChildrenElement).toHaveTextContent("To use this endpoint, make sure to enable its feature flag: test-feature-flag") + + const badgeElement = container.querySelector("[data-testid='badge']") + expect(badgeElement).toBeInTheDocument() + expect(badgeElement).toHaveTextContent("feature flag") + }) + + test("renders feature flag notice for parameter type", () => { + const { container } = render() + const tooltipElement = container.querySelector("[data-testid='tooltip']") + expect(tooltipElement).toBeInTheDocument() + const tooltipChildrenElement = container.querySelector("[data-testid='tooltip-children']") + expect(tooltipChildrenElement).toBeInTheDocument() + expect(tooltipChildrenElement).toHaveTextContent("To use this parameter, make sure to enable its feature flag: test-feature-flag") + + const badgeElement = container.querySelector("[data-testid='badge']") + expect(badgeElement).toBeInTheDocument() + expect(badgeElement).toHaveTextContent("feature flag") + }) + + test("renders feature flag notice with tooltipTextClassName", () => { + const { container } = render() + const tooltipTextElement = container.querySelector("[data-testid='tooltip-text']") + expect(tooltipTextElement).toBeInTheDocument() + expect(tooltipTextElement).toHaveClass("text-red-500") + }) + + test("renders feature flag notice with badgeClassName", () => { + const { container } = render() + const badgeElement = container.querySelector("[data-testid='badge']") + expect(badgeElement).toBeInTheDocument() + expect(badgeElement).toHaveClass("bg-red-500") + }) +}) \ No newline at end of file diff --git a/www/apps/api-reference/components/Tags/Operation/FeatureFlagNotice/index.tsx b/www/apps/api-reference/components/Tags/Operation/FeatureFlagNotice/index.tsx index f3c41fa014..70a833e788 100644 --- a/www/apps/api-reference/components/Tags/Operation/FeatureFlagNotice/index.tsx +++ b/www/apps/api-reference/components/Tags/Operation/FeatureFlagNotice/index.tsx @@ -1,3 +1,4 @@ +import React from "react" import { Badge, Link, Tooltip } from "docs-ui" export type TagsOperationFeatureFlagNoticeProps = { @@ -16,9 +17,8 @@ const TagsOperationFeatureFlagNotice = ({ return ( - To use this {type}, make sure to -
+ + To use this {type}, make sure to
text.charAt(0).toUpperCase() + text.slice(1)) + +// mock components +vi.mock("docs-ui", () => ({ + InlineCode: ({ children }: InlineCodeProps) => ( +
{children}
+ ), + Link: (props: React.HTMLAttributes) => ( + + ), + capitalize: (text: string) => mockCapitalize(text), +})) +vi.mock("@/components/MDXContent/Client", () => ({ + default: ({ content }: { content: string }) => ( +
{content}
+ ), +})) + +import TagOperationParametersDescription from ".." + +beforeEach(() => { + vi.clearAllMocks() + cleanup() +}) + +describe("rendering", () => { + test("renders default when schema has a default", async () => { + const modifiedSchema: OpenAPI.SchemaObject = { + ...mockSchema, + default: "test-default", + } + const { container } = render() + await waitFor(() => { + const defaultElement = container.querySelector("[data-testid='default']") + expect(defaultElement).toBeInTheDocument() + expect(defaultElement).toHaveTextContent("Default: " + JSON.stringify(modifiedSchema.default)) + }) + }) + + test("does not render default when schema does not have a default", () => { + const { container } = render() + const defaultElement = container.querySelector("[data-testid='default']") + expect(defaultElement).not.toBeInTheDocument() + }) + + test("renders enum when schema has an enum", async () => { + const modifiedSchema: OpenAPI.SchemaObject = { + ...mockSchema, + enum: ["test-enum1", "test-enum2"], + } + const { container } = render() + await waitFor(() => { + const enumElement = container.querySelector("[data-testid='enum']") + expect(enumElement).toBeInTheDocument() + expect(enumElement).toHaveTextContent("Enum: " + modifiedSchema.enum!.map((value) => JSON.stringify(value)).join(", ")) + }) + }) + + test("does not render enum when schema does not have an enum", () => { + const { container } = render() + const enumElement = container.querySelector("[data-testid='enum']") + expect(enumElement).not.toBeInTheDocument() + }) + + test("renders example when schema has an example", () => { + const modifiedSchema: OpenAPI.SchemaObject = { + ...mockSchema, + example: "test-example", + } + const { container } = render() + const exampleElement = container.querySelector("[data-testid='example']") + expect(exampleElement).toBeInTheDocument() + expect(exampleElement).toHaveTextContent("Example: " + JSON.stringify(modifiedSchema.example)) + }) + + test("does not render example when schema does not have an example", () => { + const { container } = render() + const exampleElement = container.querySelector("[data-testid='example']") + expect(exampleElement).not.toBeInTheDocument() + }) + + test("renders description when schema has a description", () => { + const modifiedSchema: OpenAPI.SchemaObject = { + ...mockSchema, + description: "test-description", + } + const { container } = render() + const descriptionElement = container.querySelector("[data-testid='mdx-content']") + expect(descriptionElement).toBeInTheDocument() + expect(descriptionElement).toHaveTextContent(mockCapitalize(modifiedSchema.description!)) + }) + + test("does not render description when schema does not have a description", () => { + const { container } = render() + const descriptionElement = container.querySelector("[data-testid='mdx-content']") + expect(descriptionElement).not.toBeInTheDocument() + }) + + test("renders related guide when schema has a related guide", () => { + const modifiedSchema: OpenAPI.SchemaObject = { + ...mockSchema, + externalDocs: { + url: "https://example.com", + description: "test-description", + }, + } + const { container } = render() + const relatedGuideElement = container.querySelector("[data-testid='related-guide']") + expect(relatedGuideElement).toBeInTheDocument() + expect(relatedGuideElement).toHaveTextContent("Related guide: " + modifiedSchema.externalDocs!.description) + const link = relatedGuideElement!.querySelector("[data-testid='link']") + expect(link).toHaveAttribute("href", modifiedSchema.externalDocs!.url) + expect(link).toHaveAttribute("target", "_blank") + expect(link).toHaveAttribute("variant", "content") + }) + + test("renders related guide with default description when schema has a related guide with no description", () => { + const modifiedSchema: OpenAPI.SchemaObject = { + ...mockSchema, + externalDocs: { + url: "https://example.com", + }, + } + const { container } = render() + const relatedGuideElement = container.querySelector("[data-testid='related-guide']") + expect(relatedGuideElement).toBeInTheDocument() + expect(relatedGuideElement).toHaveTextContent("Related guide: " + "Read More") + const link = relatedGuideElement!.querySelector("[data-testid='link']") + expect(link).toHaveAttribute("href", modifiedSchema.externalDocs!.url) + expect(link).toHaveAttribute("target", "_blank") + expect(link).toHaveAttribute("variant", "content") + }) + + test("does not render related guide when schema does not have a related guide", () => { + const { container } = render() + const relatedGuideElement = container.querySelector("[data-testid='related-guide']") + expect(relatedGuideElement).not.toBeInTheDocument() + }) +}) \ No newline at end of file diff --git a/www/apps/api-reference/components/Tags/Operation/Parameters/Description/index.tsx b/www/apps/api-reference/components/Tags/Operation/Parameters/Description/index.tsx index 48ce3eaf48..3dbd633c38 100644 --- a/www/apps/api-reference/components/Tags/Operation/Parameters/Description/index.tsx +++ b/www/apps/api-reference/components/Tags/Operation/Parameters/Description/index.tsx @@ -1,3 +1,4 @@ +import React from "react" import MDXContentClient from "@/components/MDXContent/Client" import type { OpenAPI } from "types" import clsx from "clsx" @@ -9,7 +10,7 @@ const InlineCode = dynamic( async () => (await import("docs-ui")).InlineCode ) as React.FC -type TagOperationParametersDescriptionProps = { +export type TagOperationParametersDescriptionProps = { schema: OpenAPI.SchemaObject } @@ -19,7 +20,7 @@ const TagOperationParametersDescription = ({ return (
{schema.default !== undefined && ( - + Default:{" "} {JSON.stringify(schema.default)} @@ -27,7 +28,7 @@ const TagOperationParametersDescription = ({ )} {schema.enum && ( - + Enum:{" "} {schema.enum.map((value, index) => ( @@ -38,7 +39,7 @@ const TagOperationParametersDescription = ({ )} {schema.example !== undefined && ( - + Example:{" "} {JSON.stringify(schema.example)} @@ -57,7 +58,7 @@ const TagOperationParametersDescription = ({ )} {schema.externalDocs && ( - + Related guide:{" "} ({ + Badge: ({ variant, children, className }: BadgeProps) => ( +
{children}
+ ), + ExpandableNotice: ({ type, link }: ExpandableNoticeProps) => ( +
+ Expandable Notice +
+ ), + FeatureFlagNotice: ({ featureFlag, type }: FeatureFlagNoticeProps) => ( +
+ Feature Flag Notice +
+ ), +})) + +import TagOperationParametersName from ".." + +beforeEach(() => { + vi.clearAllMocks() + cleanup() +}) + +describe("rendering", () => { + test("renders name", () => { + const { container } = render( + + ) + const nameElement = container.querySelector("[data-testid='name']") + expect(nameElement).toBeInTheDocument() + expect(nameElement).toHaveTextContent(mockName) + }) + + test("renders deprecated badge when schema is deprecated", () => { + const modifiedSchema: OpenAPI.SchemaObject = { + ...mockSchema, + deprecated: true, + } + const { container } = render( + + ) + const badgeElement = container.querySelector("[data-testid='badge']") + expect(badgeElement).toBeInTheDocument() + expect(badgeElement).toHaveTextContent("deprecated") + }) + + test("renders expandable notice when schema is expandable", () => { + const modifiedSchema: OpenAPI.SchemaObject = { + ...mockSchema, + "x-expandable": "expanding-relations", + } + const { container } = render( + + ) + const expandableNoticeElement = container.querySelector("[data-testid='expandable-notice']") + expect(expandableNoticeElement).toBeInTheDocument() + expect(expandableNoticeElement).toHaveTextContent("Expandable Notice") + expect(expandableNoticeElement).toHaveAttribute("data-type", "request") + expect(expandableNoticeElement).toHaveAttribute("data-link", "#expanding-relations") + }) + + test("renders feature flag notice when schema has a feature flag", () => { + const modifiedSchema: OpenAPI.SchemaObject = { + ...mockSchema, + "x-featureFlag": "test-feature-flag", + } + const { container } = render( + + ) + const featureFlagNoticeElement = container.querySelector("[data-testid='feature-flag-notice']") + expect(featureFlagNoticeElement).toBeInTheDocument() + expect(featureFlagNoticeElement).toHaveTextContent("Feature Flag Notice") + expect(featureFlagNoticeElement).toHaveAttribute("data-feature-flag", "test-feature-flag") + expect(featureFlagNoticeElement).toHaveAttribute("data-type", "type") + }) + + test("renders optional span when schema is not required", () => { + const { container } = render( + + ) + const optionalElement = container.querySelector("[data-testid='optional']") + expect(optionalElement).toBeInTheDocument() + expect(optionalElement).toHaveTextContent("optional") + }) + + test("does not render deprecated badge when schema is not deprecated", () => { + const { container } = render( + + ) + const badgeElement = container.querySelector("[data-testid='badge']") + expect(badgeElement).not.toBeInTheDocument() + }) +}) + +describe("object type schema description formatting", () => { + test("formats description for object type schema without title and no nullable", () => { + const { container } = render( + + ) + const typeDescriptionElement = container.querySelector("[data-testid='type-description']") + expect(typeDescriptionElement).toBeInTheDocument() + expect(typeDescriptionElement).toHaveTextContent("object") + }) + + test("formats description for object type schema with title and no nullable", () => { + const modifiedSchema: OpenAPI.SchemaObject = { + ...mockSchema, + title: "test-title", + } + const { container } = render( + + ) + const typeDescriptionElement = container.querySelector("[data-testid='type-description']") + expect(typeDescriptionElement).toBeInTheDocument() + expect(typeDescriptionElement).toHaveTextContent("object (test-title)") + }) + + test("formats description for object type schema with no title and nullable", () => { + const modifiedSchema: OpenAPI.SchemaObject = { + ...mockSchema, + nullable: true, + } + const { container } = render( + + ) + const typeDescriptionElement = container.querySelector("[data-testid='type-description']") + expect(typeDescriptionElement).toBeInTheDocument() + expect(typeDescriptionElement).toHaveTextContent("object or null") + }) + + test("formats description for object type schema with title and nullable", () => { + const modifiedSchema: OpenAPI.SchemaObject = { + ...mockSchema, + title: "test-title", + nullable: true, + } + const { container } = render( + + ) + const typeDescriptionElement = container.querySelector("[data-testid='type-description']") + expect(typeDescriptionElement).toBeInTheDocument() + expect(typeDescriptionElement).toHaveTextContent("object (test-title) or null") + }) +}) + +describe("array type schema description formatting", () => { + test("formats description for array type schema with no items and not nullable", () => { + const arraySchema: OpenAPI.ArraySchemaObject = { + type: "array", + // @ts-expect-error - we are testing the case where items is undefined + items: undefined, + } + const { container } = render( + + ) + const typeDescriptionElement = container.querySelector("[data-testid='type-description']") + expect(typeDescriptionElement).toBeInTheDocument() + expect(typeDescriptionElement).toHaveTextContent("Array") + }) + + test("formats description for array type schema with no items and nullable", () => { + const arraySchema: OpenAPI.ArraySchemaObject = { + type: "array", + // @ts-expect-error - we are testing the case where items is undefined + items: undefined, + nullable: true, + } + const { container } = render( + + ) + const typeDescriptionElement = container.querySelector("[data-testid='type-description']") + expect(typeDescriptionElement).toBeInTheDocument() + expect(typeDescriptionElement).toHaveTextContent("Array or null") + }) + + test("formats description for array with object items (with title) and not nullable", () => { + const arraySchema: OpenAPI.ArraySchemaObject = { + type: "array", + items: { + type: "object", + title: "test-title", + properties: {} + }, + properties: {}, + } + const { container } = render( + + ) + const typeDescriptionElement = container.querySelector("[data-testid='type-description']") + expect(typeDescriptionElement).toBeInTheDocument() + expect(typeDescriptionElement).toHaveTextContent("Array of objects (test-title)") + }) + + test("formats description for array with object items (with title) and nullable", () => { + const arraySchema: OpenAPI.ArraySchemaObject = { + type: "array", + items: { + type: "object", + title: "test-title", + properties: {}, + }, + properties: {}, + nullable: true, + } + + const { container } = render( + + ) + const typeDescriptionElement = container.querySelector("[data-testid='type-description']") + expect(typeDescriptionElement).toBeInTheDocument() + expect(typeDescriptionElement).toHaveTextContent("Array of objects (test-title) or null") + }) + + test("formats description for array with object items (without title) and not nullable", () => { + const arraySchema: OpenAPI.ArraySchemaObject = { + type: "array", + items: { + type: "object", + properties: {}, + }, + properties: {}, + } + const { container } = render( + + ) + const typeDescriptionElement = container.querySelector("[data-testid='type-description']") + expect(typeDescriptionElement).toBeInTheDocument() + expect(typeDescriptionElement).toHaveTextContent("Array of objects") + }) + + test("formats description for array with object items (without title) and nullable", () => { + const arraySchema: OpenAPI.ArraySchemaObject = { + type: "array", + items: { + type: "object", + properties: {}, + }, + properties: {}, + nullable: true, + } + const { container } = render( + + ) + const typeDescriptionElement = container.querySelector("[data-testid='type-description']") + expect(typeDescriptionElement).toBeInTheDocument() + expect(typeDescriptionElement).toHaveTextContent("Array of objects or null") + }) + + test("formats description for array with any items and not nullable", () => { + const arraySchema: OpenAPI.ArraySchemaObject = { + type: "array", + items: { + type: "string", + properties: {}, + }, + properties: {}, + } + const { container } = render( + + ) + const typeDescriptionElement = container.querySelector("[data-testid='type-description']") + expect(typeDescriptionElement).toBeInTheDocument() + expect(typeDescriptionElement).toHaveTextContent("Array of strings") + }) + + test("formats description for array with any items and nullable", () => { + const arraySchema: OpenAPI.ArraySchemaObject = { + type: "array", + items: { + type: "string", + properties: {}, + }, + properties: {}, + nullable: true, + } + const { container } = render( + + ) + const typeDescriptionElement = container.querySelector("[data-testid='type-description']") + expect(typeDescriptionElement).toBeInTheDocument() + expect(typeDescriptionElement).toHaveTextContent("Array of strings or null") + }) + + test("formats description for array with items of no type and not nullable", () => { + const arraySchema: OpenAPI.ArraySchemaObject = { + type: "array", + items: { + properties: {}, + }, + properties: {}, + } + const { container } = render( + + ) + const typeDescriptionElement = container.querySelector("[data-testid='type-description']") + expect(typeDescriptionElement).toBeInTheDocument() + expect(typeDescriptionElement).toHaveTextContent("Array of objects") + }) + + test("formats description for array with items of no type and nullable", () => { + const arraySchema: OpenAPI.ArraySchemaObject = { + type: "array", + items: { + properties: {}, + }, + properties: {}, + nullable: true, + } + const { container } = render( + + ) + const typeDescriptionElement = container.querySelector("[data-testid='type-description']") + expect(typeDescriptionElement).toBeInTheDocument() + expect(typeDescriptionElement).toHaveTextContent("Array of objects or null") + }) +}) + +describe("union type schema description formatting", () => { + test("formats description for union type schema with allOf and not nullable", () => { + const unionSchema: OpenAPI.SchemaObject = { + allOf: [ + { type: "object", properties: {} }, + { type: "string", properties: {} }, + ], + properties: {}, + } + const { container } = render( + + ) + const typeDescriptionElement = container.querySelector("[data-testid='type-description']") + expect(typeDescriptionElement).toBeInTheDocument() + expect(typeDescriptionElement).toHaveTextContent("object or string") + }) + + test("formats description for union type schema with allOf and nullable", () => { + const unionSchema: OpenAPI.SchemaObject = { + allOf: [ + { type: "object", properties: {} }, + { type: "string", properties: {} }, + ], + properties: {}, + nullable: true, + } + + const { container } = render( + + ) + const typeDescriptionElement = container.querySelector("[data-testid='type-description']") + expect(typeDescriptionElement).toBeInTheDocument() + expect(typeDescriptionElement).toHaveTextContent("object or string or null") + }) + + test("formats description for union type schema with anyOf and not nullable", () => { + const unionSchema: OpenAPI.SchemaObject = { + anyOf: [ + { type: "object", properties: {} }, + { type: "string", properties: {} }, + ], + properties: {}, + } + + const { container } = render( + + ) + const typeDescriptionElement = container.querySelector("[data-testid='type-description']") + expect(typeDescriptionElement).toBeInTheDocument() + expect(typeDescriptionElement).toHaveTextContent("object or string") + }) + + test("formats description for union type schema with anyOf and nullable", () => { + const unionSchema: OpenAPI.SchemaObject = { + anyOf: [ + { type: "object", properties: {} }, + { type: "string", properties: {} }, + ], + properties: {}, + nullable: true, + } + + const { container } = render( + + ) + const typeDescriptionElement = container.querySelector("[data-testid='type-description']") + expect(typeDescriptionElement).toBeInTheDocument() + expect(typeDescriptionElement).toHaveTextContent("object or string or null") + }) + + test("formats description for union type schema with same types and not nullable", () => { + const unionSchema: OpenAPI.SchemaObject = { + anyOf: [ + { type: "object", properties: {} }, + { type: "object", properties: {} }, + ], + properties: {}, + nullable: true, + } + + const { container } = render( + + ) + const typeDescriptionElement = container.querySelector("[data-testid='type-description']") + expect(typeDescriptionElement).toBeInTheDocument() + expect(typeDescriptionElement).toHaveTextContent("object") + }) + + test("formats description for union type schema with same types and nullable", () => { + const unionSchema: OpenAPI.SchemaObject = { + anyOf: [ + { type: "object", properties: {} }, + { type: "object", properties: {} }, + ], + properties: {}, + nullable: true, + } + const { container } = render( + + ) + const typeDescriptionElement = container.querySelector("[data-testid='type-description']") + expect(typeDescriptionElement).toBeInTheDocument() + expect(typeDescriptionElement).toHaveTextContent("object or null") + }) + + test("gives precedence to allOf over anyOf", () => { + const unionSchema: OpenAPI.SchemaObject = { + allOf: [ + { type: "object", properties: {} }, + ], + anyOf: [ + { type: "string", properties: {} }, + ], + properties: {}, + } + + const { container } = render( + + ) + const typeDescriptionElement = container.querySelector("[data-testid='type-description']") + expect(typeDescriptionElement).toBeInTheDocument() + expect(typeDescriptionElement).toHaveTextContent("object") + expect(typeDescriptionElement).not.toHaveTextContent("string") + }) +}) + +describe("oneOf type schema description formatting", () => { + test("formats description for oneOf type schema with one item (without title) and not nullable", () => { + const oneOfSchema: OpenAPI.SchemaObject = { + oneOf: [ + { type: "object", properties: {} }, + ], + properties: {}, + } + const { container } = render( + + ) + const typeDescriptionElement = container.querySelector("[data-testid='type-description']") + expect(typeDescriptionElement).toBeInTheDocument() + expect(typeDescriptionElement).toHaveTextContent("object") + }) + + test("formats description for oneOf type schema with one item (without title) and nullable", () => { + const oneOfSchema: OpenAPI.SchemaObject = { + oneOf: [ + { type: "object", properties: {} }, + ], + properties: {}, + nullable: true, + } + const { container } = render( + + ) + const typeDescriptionElement = container.querySelector("[data-testid='type-description']") + expect(typeDescriptionElement).toBeInTheDocument() + expect(typeDescriptionElement).toHaveTextContent("object or null") + }) + + test("formats description for oneOf type schema with one item (with title) and not nullable", () => { + const oneOfSchema: OpenAPI.SchemaObject = { + oneOf: [ + { type: "object", title: "test-title", properties: {} }, + ], + properties: {}, + } + const { container } = render( + + ) + const typeDescriptionElement = container.querySelector("[data-testid='type-description']") + expect(typeDescriptionElement).toBeInTheDocument() + expect(typeDescriptionElement).toHaveTextContent("test-title") + }) + + test("formats description for oneOf type schema with one item (with title) and nullable", () => { + const oneOfSchema: OpenAPI.SchemaObject = { + oneOf: [ + { type: "object", title: "test-title", properties: {} }, + ], + properties: {}, + nullable: true, + } + const { container } = render( + + ) + const typeDescriptionElement = container.querySelector("[data-testid='type-description']") + expect(typeDescriptionElement).toBeInTheDocument() + expect(typeDescriptionElement).toHaveTextContent("test-title or null") + }) + + test("formats description for oneOf type schema with array items (with type) and not nullable", () => { + const oneOfSchema: OpenAPI.SchemaObject = { + oneOf: [ + { type: "array", items: { type: "string", properties: {} }, properties: {} }, + ], + properties: {}, + } + const { container } = render( + + ) + const typeDescriptionElement = container.querySelector("[data-testid='type-description']") + expect(typeDescriptionElement).toBeInTheDocument() + expect(typeDescriptionElement).toHaveTextContent("array of strings") + }) + + test("formats description for oneOf type schema with array items (with type) and nullable", () => { + const oneOfSchema: OpenAPI.SchemaObject = { + oneOf: [ + { type: "array", items: { type: "string", properties: {} }, properties: {} }, + ], + properties: {}, + nullable: true, + } + const { container } = render( + + ) + const typeDescriptionElement = container.querySelector("[data-testid='type-description']") + expect(typeDescriptionElement).toBeInTheDocument() + expect(typeDescriptionElement).toHaveTextContent("array of strings or null") + }) + + test("formats description for oneOf type schema with array items (without type) and not nullable", () => { + const oneOfSchema: OpenAPI.SchemaObject = { + oneOf: [ + { type: "array", items: { properties: {} }, properties: {} }, + ], + properties: {}, + } + const { container } = render( + + ) + const typeDescriptionElement = container.querySelector("[data-testid='type-description']") + expect(typeDescriptionElement).toBeInTheDocument() + expect(typeDescriptionElement).toHaveTextContent("array") + }) + + test("formats description for oneOf type schema with array items (without type) and nullable", () => { + const oneOfSchema: OpenAPI.SchemaObject = { + oneOf: [ + { type: "array", items: { properties: {} }, properties: {} }, + ], + properties: {}, + nullable: true, + } + + const { container } = render( + + ) + const typeDescriptionElement = container.querySelector("[data-testid='type-description']") + expect(typeDescriptionElement).toBeInTheDocument() + expect(typeDescriptionElement).toHaveTextContent("array or null") + }) + + test("formats description for oneOf type schema with mixed items and not nullable", () => { + const oneOfSchema: OpenAPI.SchemaObject = { + oneOf: [ + { type: "object", properties: {} }, + { type: "array", items: { type: "string", properties: {} }, properties: {} }, + ], + properties: {}, + } + + const { container } = render( + + ) + const typeDescriptionElement = container.querySelector("[data-testid='type-description']") + expect(typeDescriptionElement).toBeInTheDocument() + expect(typeDescriptionElement).toHaveTextContent("object or array of strings") + }) + + test("formats description for oneOf type schema with mixed items and nullable", () => { + const oneOfSchema: OpenAPI.SchemaObject = { + oneOf: [ + { type: "object", properties: {} }, + { type: "array", items: { type: "string", properties: {} }, properties: {} }, + ], + properties: {}, + nullable: true, + } + const { container } = render( + + ) + const typeDescriptionElement = container.querySelector("[data-testid='type-description']") + expect(typeDescriptionElement).toBeInTheDocument() + expect(typeDescriptionElement).toHaveTextContent("object or array of strings or null") + }) +}) + +describe("default type schema description formatting", () => { + test("formats description for default type schema with type, no nullable, no format", () => { + const defaultSchema: OpenAPI.SchemaObject = { + type: "string", + properties: {} + } + const { container } = render( + + ) + const typeDescriptionElement = container.querySelector("[data-testid='type-description']") + expect(typeDescriptionElement).toBeInTheDocument() + expect(typeDescriptionElement).toHaveTextContent("string") + }) + + test("formats description for default type schema with type, nullable, no format", () => { + const defaultSchema: OpenAPI.SchemaObject = { + type: "string", + properties: {}, + nullable: true, + } + const { container } = render( + + ) + const typeDescriptionElement = container.querySelector("[data-testid='type-description']") + expect(typeDescriptionElement).toBeInTheDocument() + expect(typeDescriptionElement).toHaveTextContent("string or null") + }) + + test("formats description for default type schema with type, no nullable, with format", () => { + const defaultSchema: OpenAPI.SchemaObject = { + type: "string", + properties: {}, + format: "date-time", + } + const { container } = render( + + ) + const typeDescriptionElement = container.querySelector("[data-testid='type-description']") + expect(typeDescriptionElement).toBeInTheDocument() + expect(typeDescriptionElement).toHaveTextContent("string ") + }) + + test("formats description for default type schema with type, nullable, with format", () => { + const defaultSchema: OpenAPI.SchemaObject = { + type: "string", + properties: {}, + format: "date-time", + nullable: true, + } + const { container } = render( + + ) + const typeDescriptionElement = container.querySelector("[data-testid='type-description']") + expect(typeDescriptionElement).toBeInTheDocument() + expect(typeDescriptionElement).toHaveTextContent("string or null") + }) + + test("formats description for default type schema without type and no nullable, no format", () => { + const defaultSchema: OpenAPI.SchemaObject = { + properties: {} + } + const { container } = render( + + ) + const typeDescriptionElement = container.querySelector("[data-testid='type-description']") + expect(typeDescriptionElement).toBeInTheDocument() + expect(typeDescriptionElement).toHaveTextContent("any") + }) + + test("formats description for default type schema without type and nullable, no format", () => { + const defaultSchema: OpenAPI.SchemaObject = { + properties: {}, + nullable: true, + } + const { container } = render( + + ) + const typeDescriptionElement = container.querySelector("[data-testid='type-description']") + expect(typeDescriptionElement).toBeInTheDocument() + expect(typeDescriptionElement).toHaveTextContent("any or null") + }) + + test("formats description for default type schema without type and no nullable, with format", () => { + const defaultSchema: OpenAPI.SchemaObject = { + properties: {}, + format: "date-time", + } + const { container } = render( + + ) + const typeDescriptionElement = container.querySelector("[data-testid='type-description']") + expect(typeDescriptionElement).toBeInTheDocument() + expect(typeDescriptionElement).toHaveTextContent("any ") + }) + + test("formats description for default type schema without type and nullable, with format", () => { + const defaultSchema: OpenAPI.SchemaObject = { + properties: {}, + format: "date-time", + nullable: true, + } + const { container } = render( + + ) + const typeDescriptionElement = container.querySelector("[data-testid='type-description']") + expect(typeDescriptionElement).toBeInTheDocument() + expect(typeDescriptionElement).toHaveTextContent("any or null") + }) +}) \ No newline at end of file diff --git a/www/apps/api-reference/components/Tags/Operation/Parameters/Name/index.tsx b/www/apps/api-reference/components/Tags/Operation/Parameters/Name/index.tsx index 4eba3f1d22..5ffaee335d 100644 --- a/www/apps/api-reference/components/Tags/Operation/Parameters/Name/index.tsx +++ b/www/apps/api-reference/components/Tags/Operation/Parameters/Name/index.tsx @@ -1,3 +1,4 @@ +import React from "react" import type { OpenAPI } from "types" import { Badge, ExpandableNotice, FeatureFlagNotice } from "docs-ui" import { Fragment } from "react" @@ -26,7 +27,7 @@ const TagOperationParametersName = ({ case schema.type === "array": typeDescription = ( <> - {schema.type === "array" && formatArrayDescription(schema.items)} + {formatArrayDescription((schema as OpenAPI.ArraySchemaObject).items)} {schema.nullable ? ` or null` : ""} ) @@ -35,7 +36,7 @@ const TagOperationParametersName = ({ case schema.allOf !== undefined: typeDescription = ( <> - {formatUnionDescription(schema.allOf)} + {formatUnionDescription(schema.allOf || schema.anyOf)} {schema.nullable ? ` or null` : ""} ) @@ -43,7 +44,7 @@ const TagOperationParametersName = ({ case schema.oneOf !== undefined: typeDescription = ( <> - {schema.oneOf?.map((item, index) => ( + {schema.oneOf!.map((item, index) => ( {index !== 0 && <> or } {item.type !== "array" && <>{item.title || item.type}} @@ -60,16 +61,21 @@ const TagOperationParametersName = ({ typeDescription = ( <> {!schema.type ? "any" : schema.type} - {schema.nullable ? ` or null` : ""} {schema.format ? ` <${schema.format}>` : ""} + {schema.nullable ? ` or null` : ""} ) } return ( - {name} - + + {name} + + {typeDescription} {schema.deprecated && ( @@ -84,7 +90,10 @@ const TagOperationParametersName = ({ )} {!isRequired && ( - + optional )} diff --git a/www/apps/api-reference/components/Tags/Operation/Parameters/Nested/index.tsx b/www/apps/api-reference/components/Tags/Operation/Parameters/Nested/index.tsx index ba79ebf6f9..ce0336f016 100644 --- a/www/apps/api-reference/components/Tags/Operation/Parameters/Nested/index.tsx +++ b/www/apps/api-reference/components/Tags/Operation/Parameters/Nested/index.tsx @@ -1,3 +1,4 @@ +import React from "react" import clsx from "clsx" export type TagsOperationParametersNestedProps = diff --git a/www/apps/api-reference/components/Tags/Operation/Parameters/Section/__tests__/index.test.tsx b/www/apps/api-reference/components/Tags/Operation/Parameters/Section/__tests__/index.test.tsx new file mode 100644 index 0000000000..383b10e886 --- /dev/null +++ b/www/apps/api-reference/components/Tags/Operation/Parameters/Section/__tests__/index.test.tsx @@ -0,0 +1,68 @@ +import React from "react" +import { beforeEach, describe, expect, test, vi } from "vitest" +import { cleanup, render, waitFor } from "@testing-library/react" +import { OpenAPI } from "types" +import { TagOperationParametersProps } from "../../../Parameters" + +// mock data +const mockSchema: OpenAPI.SchemaObject = { + type: "object", + properties: { + name: { type: "string", properties: {} }, + }, +} + +// mock components +vi.mock("@/components/Tags/Operation/Parameters", () => ({ + default: ({ schemaObject }: TagOperationParametersProps) => ( +
{JSON.stringify(schemaObject)}
+ ), +})) +vi.mock("docs-ui", () => ({ + Loading: () =>
Loading...
, +})) + +import TagsOperationParametersSection from ".." + +beforeEach(() => { + vi.clearAllMocks() + cleanup() +}) + +describe("rendering", () => { + test("renders parameters", async () => { + const { container } = render() + await waitFor(() => { + const parametersElement = container.querySelector("[data-testid='parameters']") + expect(parametersElement).toBeInTheDocument() + expect(parametersElement).toHaveTextContent(JSON.stringify(mockSchema)) + }) + }) + test("renders header when header is provided", () => { + const { container } = render( + + ) + const headerElement = container.querySelector("[data-testid='header']") + expect(headerElement).toBeInTheDocument() + expect(headerElement).toHaveTextContent("test-header") + }) + + test("does not render header when header is not provided", () => { + const { container } = render() + const headerElement = container.querySelector("[data-testid='header']") + expect(headerElement).not.toBeInTheDocument() + }) + + test("renders content type when content type is provided", () => { + const { container } = render() + const contentTypeElement = container.querySelector("[data-testid='content-type']") + expect(contentTypeElement).toBeInTheDocument() + expect(contentTypeElement).toHaveTextContent("Content type: test-content-type") + }) + + test("does not render content type when content type is not provided", () => { + const { container } = render() + const contentTypeElement = container.querySelector("[data-testid='content-type']") + expect(contentTypeElement).not.toBeInTheDocument() + }) +}) \ No newline at end of file diff --git a/www/apps/api-reference/components/Tags/Operation/Parameters/Section/index.tsx b/www/apps/api-reference/components/Tags/Operation/Parameters/Section/index.tsx index 4f648e62fd..fd7168ec43 100644 --- a/www/apps/api-reference/components/Tags/Operation/Parameters/Section/index.tsx +++ b/www/apps/api-reference/components/Tags/Operation/Parameters/Section/index.tsx @@ -1,3 +1,4 @@ +import React from "react" import type { OpenAPI } from "types" import clsx from "clsx" import type { TagOperationParametersProps } from ".." @@ -27,12 +28,13 @@ const TagsOperationParametersSection = ({ {header && (

{header}

)} {contentType && ( - + Content type: {contentType} )} diff --git a/www/apps/api-reference/components/Tags/Operation/Parameters/Types/Array/__tests__/index.test.tsx b/www/apps/api-reference/components/Tags/Operation/Parameters/Types/Array/__tests__/index.test.tsx new file mode 100644 index 0000000000..ea5ce5ff2f --- /dev/null +++ b/www/apps/api-reference/components/Tags/Operation/Parameters/Types/Array/__tests__/index.test.tsx @@ -0,0 +1,150 @@ +import React from "react" +import { beforeEach, describe, expect, test, vi } from "vitest" +import { cleanup, render, waitFor } from "@testing-library/react" +import { OpenAPI } from "types" +import { TagOperationParametersProps } from "../../.." +import { TagOperationParametersDefaultProps } from "../../Default" + +// mock components +vi.mock("@/components/Tags/Operation/Parameters", () => ({ + default: ({ schemaObject }: TagOperationParametersProps) => ( +
{JSON.stringify(schemaObject)}
+ ), +})) +vi.mock("@/components/Tags/Operation/Parameters/Types/Default", () => ({ + default: ({ schema }: TagOperationParametersDefaultProps) => ( +
{JSON.stringify(schema)}
+ ), +})) +vi.mock("@/components/Tags/Operation/Parameters/Nested", () => ({ + default: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), +})) +vi.mock("docs-ui", () => ({ + Loading: () =>
Loading...
, + Details: ({ children, summaryElm }: { children: React.ReactNode, summaryElm: React.ReactNode }) => ( +
+
{summaryElm}
+
{children}
+
+ ), +})) + +import TagsOperationParametersArray from ".." + +beforeEach(() => { + vi.clearAllMocks() + cleanup() +}) + +describe("rendering", () => { + test("does not render when schema is not an array", () => { + const mockSchema: OpenAPI.SchemaObject = { + type: "object", + properties: {} + } + const { container } = render( + + ) + expect(container).toBeEmptyDOMElement() + }) + + test("renders default when items type is not an array, object, or undefined", async () => { + const mockSchema: OpenAPI.SchemaObject = { + type: "array", + items: { type: "string", properties: {} }, + properties: {}, + } + const { container } = render() + await waitFor(() => { + const defaultElement = container.querySelector("[data-testid='default']") + expect(defaultElement).toBeInTheDocument() + expect(defaultElement).toHaveTextContent(JSON.stringify(mockSchema)) + }) + }) + + test("renders default when items is undefined", async () => { + const mockSchema: OpenAPI.SchemaObject = { + type: "array", + // @ts-expect-error - we are testing the case where items is undefined + items: undefined, + properties: {}, + } + const { container } = render( + + ) + await waitFor(() => { + const defaultElement = container.querySelector("[data-testid='default']") + expect(defaultElement).toBeInTheDocument() + expect(defaultElement).toHaveTextContent(JSON.stringify({ ...mockSchema, items: undefined })) + }) + }) + + test("renders default when items type is an object with no properties, allOf, anyOf, or oneOf", async () => { + const modifiedSchema: OpenAPI.SchemaObject = { + type: "array", + // @ts-expect-error - we are testing the case where items is an object with no properties, allOf, anyOf, or oneOf + items: { type: "object" }, + properties: {}, + } + const { container } = render() + await waitFor(() => { + const defaultElement = container.querySelector("[data-testid='default']") + expect(defaultElement).toBeInTheDocument() + expect(defaultElement).toHaveTextContent(JSON.stringify(modifiedSchema)) + }) + }) + + test("renders details when items type is an object with properties, allOf, anyOf, or oneOf", () => { + const modifiedSchema: OpenAPI.SchemaObject = { + type: "array", + items: { type: "object", properties: { name: { type: "string", properties: {} } } }, + properties: {}, + } + const { container } = render() + const detailsElement = container.querySelector("[data-testid='details']") + expect(detailsElement).toBeInTheDocument() + expect(detailsElement).toHaveTextContent(JSON.stringify(modifiedSchema)) + }) + + test("renders details when items type is an array", () => { + const modifiedSchema: OpenAPI.SchemaObject = { + type: "array", + items: { + type: "array", + items: { type: "string", properties: {} }, + properties: {}, + }, + properties: {}, + } + const { container } = render() + const detailsElement = container.querySelector("[data-testid='details']") + expect(detailsElement).toBeInTheDocument() + expect(detailsElement).toHaveTextContent(JSON.stringify(modifiedSchema)) + }) + + test("renders items in nested parameters when items type is an array", () => { + const modifiedSchema: OpenAPI.SchemaObject = { + type: "array", + items: { type: "array", items: { type: "string", properties: {} }, properties: {} }, + properties: {}, + } + const { container } = render() + const nestedElement = container.querySelector("[data-testid='nested']") + expect(nestedElement).toBeInTheDocument() + expect(nestedElement).toHaveTextContent(JSON.stringify(modifiedSchema.items)) + }) + + test("renders items in nested parameters when items type is an object", () => { + const modifiedSchema: OpenAPI.SchemaObject = { + type: "array", + items: { type: "object", properties: { name: { type: "string", properties: {} } } }, + properties: {}, + } + const { container } = render() + const nestedElement = container.querySelector("[data-testid='nested']") + expect(nestedElement).toBeInTheDocument() + expect(nestedElement).toHaveTextContent(JSON.stringify(modifiedSchema.items)) + }) +}) \ No newline at end of file diff --git a/www/apps/api-reference/components/Tags/Operation/Parameters/Types/Array/index.tsx b/www/apps/api-reference/components/Tags/Operation/Parameters/Types/Array/index.tsx index 82bf25e002..b9e4253114 100644 --- a/www/apps/api-reference/components/Tags/Operation/Parameters/Types/Array/index.tsx +++ b/www/apps/api-reference/components/Tags/Operation/Parameters/Types/Array/index.tsx @@ -1,3 +1,4 @@ +import React from "react" import type { OpenAPI } from "types" import dynamic from "next/dynamic" import type { TagOperationParametersDefaultProps } from "../Default" @@ -7,14 +8,14 @@ import { Details, Loading } from "docs-ui" const TagOperationParametersDefault = dynamic( - async () => import("../Default"), + async () => import("@/components/Tags/Operation/Parameters/Types/Default"), { loading: () => , } ) as React.FC const TagOperationParameters = dynamic( - async () => import("../.."), + async () => import("@/components/Tags/Operation/Parameters"), { loading: () => , } diff --git a/www/apps/api-reference/components/Tags/Operation/Parameters/Types/Default/__tests__/index.test.tsx b/www/apps/api-reference/components/Tags/Operation/Parameters/Types/Default/__tests__/index.test.tsx new file mode 100644 index 0000000000..e1fc6fb1b8 --- /dev/null +++ b/www/apps/api-reference/components/Tags/Operation/Parameters/Types/Default/__tests__/index.test.tsx @@ -0,0 +1,80 @@ +import React from "react" +import { beforeEach, describe, expect, test, vi } from "vitest" +import { cleanup, render, waitFor } from "@testing-library/react" +import { OpenAPI } from "types" +import { TagOperationParametersNameProps } from "../../../Name" +import { TagOperationParametersDescriptionProps } from "../../../Description" + +// mock data +const mockSchema: OpenAPI.SchemaObject = { + type: "string", + properties: {}, +} + +// mock components +vi.mock("@/components/Tags/Operation/Parameters/Description", () => ({ + default: ({ schema }: TagOperationParametersDescriptionProps) => ( +
{JSON.stringify(schema)}
+ ), +})) +vi.mock("@/components/Tags/Operation/Parameters/Name", () => ({ + default: ({ name, isRequired, schema }: TagOperationParametersNameProps) => ( +
{name} {isRequired ? "required" : "optional"} {JSON.stringify(schema)}
+ ), +})) + +import TagsOperationParametersDefault from ".." + +beforeEach(() => { + vi.clearAllMocks() + cleanup() +}) + +describe("rendering", () => { + test("renders description", async () => { + const { container } = render() + const descriptionElement = container.querySelector("[data-testid='description']") + expect(descriptionElement).toBeInTheDocument() + expect(descriptionElement).toHaveTextContent(JSON.stringify(mockSchema)) + }) + + test("renders name when name is provided", () => { + const { container } = render() + const nameElement = container.querySelector("[data-testid='name']") + expect(nameElement).toBeInTheDocument() + expect(nameElement).toHaveTextContent("test-name") + }) + + test("renders name with required when isRequired is true", () => { + const { container } = render() + const nameElement = container.querySelector("[data-testid='name']") + expect(nameElement).toBeInTheDocument() + expect(nameElement).toHaveTextContent("test-name required") + }) + + test("does not render name when name is not provided", () => { + const { container } = render() + const nameElement = container.querySelector("[data-testid='name']") + expect(nameElement).not.toBeInTheDocument() + }) + + test("add expandable class when expandable is true", () => { + const { container } = render() + const element = container.querySelector("[data-testid='default']") + expect(element).toHaveClass("w-[calc(100%-16px)]") + expect(element).not.toHaveClass("w-full pl-1") + }) + + test("does not add expandable class when expandable is false", () => { + const { container } = render() + const element = container.querySelector("[data-testid='default']") + expect(element).not.toHaveClass("w-[calc(100%-16px)]") + expect(element).toHaveClass("w-full pl-1") + }) + + test("adds className when provided", () => { + const { container } = render() + const element = container.querySelector("[data-testid='default']") + expect(element).toHaveClass("test-class") + }) +}) \ No newline at end of file diff --git a/www/apps/api-reference/components/Tags/Operation/Parameters/Types/Default/index.tsx b/www/apps/api-reference/components/Tags/Operation/Parameters/Types/Default/index.tsx index e094cd6680..92bbe78ca4 100644 --- a/www/apps/api-reference/components/Tags/Operation/Parameters/Types/Default/index.tsx +++ b/www/apps/api-reference/components/Tags/Operation/Parameters/Types/Default/index.tsx @@ -1,3 +1,4 @@ +import React from "react" import type { OpenAPI } from "types" import TagOperationParametersDescription from "../../Description" import clsx from "clsx" @@ -26,6 +27,7 @@ const TagOperationParametersDefault = ({ !expandable && "w-full pl-1", className )} + data-testid="default" > {name && ( ({ + default: ({ schema }: TagOperationParametersDefaultProps) => ( +
{JSON.stringify(schema)}
+ ), +})) +vi.mock("@/components/Tags/Operation/Parameters/Nested", () => ({ + default: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), +})) +vi.mock("docs-ui", () => ({ + Loading: () =>
Loading...
, + Details: ({ children, summaryElm }: { children: React.ReactNode, summaryElm: React.ReactNode }) => ( +
+
{summaryElm}
+
{children}
+
+ ), +})) +vi.mock("@/components/Tags/Operation/Parameters", () => ({ + default: ({ schemaObject, isRequired, isExpanded }: TagOperationParametersProps) => ( +
+ {JSON.stringify(schemaObject)} {isRequired ? "required" : "optional"} +
+ ), +})) +vi.mock("@/utils/check-required", () => ({ + default: (schema: OpenAPI.SchemaObject, property: string) => mockCheckRequired(schema, property), +})) + +import TagsOperationParametersObject from ".." + +beforeEach(() => { + vi.clearAllMocks() + cleanup() +}) + +describe("rendering", () => { + test("does not render when schema is not an object", () => { + const modifiedSchema: OpenAPI.SchemaObject = { + type: "array", + items: { type: "string", properties: {} }, + properties: {}, + } + const { container } = render() + expect(container).toBeEmptyDOMElement() + }) + + test("does not render when schema type is undefined", () => { + const modifiedSchema: OpenAPI.SchemaObject = { + type: undefined, + properties: {}, + } + const { container } = render() + expect(container).toBeEmptyDOMElement() + }) + + test("does not render when properties is empty and additionalProperties is empty and name is not provided", () => { + const modifiedSchema: OpenAPI.SchemaObject = { + type: "object", + properties: {}, + additionalProperties: undefined, + } + const { container } = render() + expect(container).toBeEmptyDOMElement() + }) + + test("renders description only when properties is empty and additionalProperties is empty and name is provided", async () => { + const modifiedSchema: OpenAPI.SchemaObject = { + type: "object", + properties: {}, + additionalProperties: undefined, + } + const { container } = render() + await waitFor(() => { + const defaultElement = container.querySelector("[data-testid='default']") + expect(defaultElement).toBeInTheDocument() + expect(defaultElement).toHaveTextContent("object") + const parametersElement = container.querySelector("[data-testid='parameters']") + expect(parametersElement).not.toBeInTheDocument() + }) + }) + + test("renders parameters only when topLevel is true", async () => { + const modifiedSchema: OpenAPI.SchemaObject = { + type: "object", + properties: { + name: { type: "string", properties: {} }, + }, + } + const { container } = render() + await waitFor(() => { + const parametersElement = container.querySelector("[data-testid='parameters']") + expect(parametersElement).toBeInTheDocument() + expect(parametersElement).toHaveTextContent(JSON.stringify({ + type: "string", + properties: {}, + parameterName: "name", + })) + const nestedElement = container.querySelector("[data-testid='nested']") + expect(nestedElement).not.toBeInTheDocument() + const detailsElement = container.querySelector("[data-testid='details']") + expect(detailsElement).not.toBeInTheDocument() + }) + }) + + test("renders description and properties when properties is not empty", async () => { + const modifiedSchema: OpenAPI.SchemaObject = { + type: "object", + properties: { + name: { type: "string", properties: {} }, + }, + } + const { container } = render() + await waitFor(() => { + const descriptionElement = container.querySelector("[data-testid='default']") + expect(descriptionElement).toBeInTheDocument() + expect(descriptionElement).toHaveTextContent(JSON.stringify(modifiedSchema)) + const parametersElement = container.querySelector("[data-testid='parameters']") + expect(parametersElement).toBeInTheDocument() + expect(parametersElement).toHaveTextContent(JSON.stringify({ + type: "string", + properties: {}, + parameterName: "name", + })) + const nestedElement = container.querySelector("[data-testid='nested']") + expect(nestedElement).toBeInTheDocument() + }) + }) + + test("renders description and properties when additionalProperties is not empty", async () => { + const modifiedSchema: OpenAPI.SchemaObject = { + type: "object", + properties: {}, + additionalProperties: { type: "object", properties: { + name: { type: "string", properties: {} }, + } }, + } + const { container } = render() + await waitFor(() => { + const descriptionElement = container.querySelector("[data-testid='default']") + expect(descriptionElement).toBeInTheDocument() + expect(descriptionElement).toHaveTextContent("object") + }) + }) + + test("renders description in summary when isExpanded is true", async () => { + const modifiedSchema: OpenAPI.SchemaObject = { + type: "object", + properties: { + name: { type: "string", properties: {} }, + }, + } + const { container } = render() + await waitFor(() => { + const summaryElement = container.querySelector("summary") + expect(summaryElement).toBeInTheDocument() + const descriptionElement = summaryElement?.querySelector("[data-testid='default']") + expect(descriptionElement).toBeInTheDocument() + expect(descriptionElement).toHaveTextContent("object") + }) + }) +}) + +describe("parameters rendering", () => { + test("renders parameters when not empty", async () => { + const modifiedSchema: OpenAPI.SchemaObject = { + type: "object", + properties: { + name: { type: "string", properties: {} }, + }, + } + const { container } = render() + await waitFor(() => { + const parametersElement = container.querySelector("[data-testid='parameters']") + expect(parametersElement).toBeInTheDocument() + expect(parametersElement).toHaveTextContent(JSON.stringify({ + type: "string", + properties: {}, + parameterName: "name", + })) + }) + }) + + test("renders parameters when additionalProperties is not empty", async () => { + const modifiedSchema: OpenAPI.SchemaObject = { + type: "object", + properties: {}, + additionalProperties: { type: "object", properties: { + name: { type: "string", properties: {} }, + } }, + } + const { container } = render() + await waitFor(() => { + const parametersElement = container.querySelector("[data-testid='parameters']") + expect(parametersElement).toBeInTheDocument() + expect(parametersElement).toHaveTextContent(JSON.stringify({ + type: "string", + properties: {}, + parameterName: "name", + })) + }) + }) + + test("gives precedence to properties over additionalProperties", async () => { + const modifiedSchema: OpenAPI.SchemaObject = { + type: "object", + properties: { + name: { type: "string", properties: {} }, + }, + additionalProperties: { type: "number", properties: {} }, + } + const { container } = render() + await waitFor(() => { + const parametersElement = container.querySelector("[data-testid='parameters']") + expect(parametersElement).toBeInTheDocument() + expect(parametersElement).toHaveTextContent(JSON.stringify({ + type: "string", + properties: {}, + parameterName: "name", + })) + }) + }) + + test("sorts properties to show required fields first", async () => { + mockCheckRequired.mockReturnValue(false) + const modifiedSchema: OpenAPI.SchemaObject = { + type: "object", + properties: { + name: { type: "string", properties: {}, isRequired: false }, + age: { type: "number", properties: {}, isRequired: true }, + }, + } + const { container } = render() + await waitFor(() => { + const parametersElement = container.querySelectorAll("[data-testid='parameters']") + expect(parametersElement).toHaveLength(2) + expect(parametersElement[0]).toHaveTextContent(JSON.stringify({ + type: "number", + properties: {}, + isRequired: true, + parameterName: "age", + })) + expect(parametersElement[1]).toHaveTextContent(JSON.stringify({ + type: "string", + properties: {}, + isRequired: false, + parameterName: "name", + })) + }) + }) + + test("adds hr between properties", async () => { + const modifiedSchema: OpenAPI.SchemaObject = { + type: "object", + properties: { + name: { type: "string", properties: {} }, + age: { type: "number", properties: {} }, + }, + } + const { container } = render() + await waitFor(() => { + const hrElements = container.querySelectorAll("hr") + expect(hrElements).toHaveLength(1) + expect(hrElements[0]).toBeInTheDocument() + expect(hrElements[0]).toHaveClass("bg-medusa-border-base my-0") + }) + }) + + test("renders property as required when the property's isRequired is true", async () => { + const modifiedSchema: OpenAPI.SchemaObject = { + type: "object", + properties: { + name: { type: "string", properties: {}, isRequired: true }, + }, + } + const { container } = render() + await waitFor(() => { + const parametersElement = container.querySelector("[data-testid='parameters']") + expect(parametersElement).toBeInTheDocument() + expect(parametersElement).toHaveTextContent("required") + }) + }) + + test("renders property as required when property's isRequired is true and checkRequired returns true", async () => { + const modifiedSchema: OpenAPI.SchemaObject = { + type: "object", + properties: { + name: { type: "string", properties: {}, isRequired: true }, + }, + } + mockCheckRequired.mockReturnValue(true) + const { container } = render() + await waitFor(() => { + const parametersElement = container.querySelector("[data-testid='parameters']") + expect(parametersElement).toBeInTheDocument() + expect(parametersElement).toHaveTextContent("required") + }) + }) + + test("renders property as optional when the property's isRequired is false and checkRequired returns false", async () => { + const modifiedSchema: OpenAPI.SchemaObject = { + type: "object", + properties: { + name: { type: "string", properties: {}, isRequired: false }, + }, + } + mockCheckRequired.mockReturnValue(false) + const { container } = render() + await waitFor(() => { + const parametersElement = container.querySelector("[data-testid='parameters']") + expect(parametersElement).toBeInTheDocument() + expect(parametersElement).toHaveTextContent("optional") + }) + }) +}) \ No newline at end of file diff --git a/www/apps/api-reference/components/Tags/Operation/Parameters/Types/Object/index.tsx b/www/apps/api-reference/components/Tags/Operation/Parameters/Types/Object/index.tsx index 8391f194bf..957c51d676 100644 --- a/www/apps/api-reference/components/Tags/Operation/Parameters/Types/Object/index.tsx +++ b/www/apps/api-reference/components/Tags/Operation/Parameters/Types/Object/index.tsx @@ -1,5 +1,6 @@ "use client" +import React from "react" import type { OpenAPI } from "types" import TagOperationParametersDefault from "../Default" import dynamic from "next/dynamic" @@ -10,7 +11,7 @@ import { Loading, type DetailsProps } from "docs-ui" import { Fragment, useMemo } from "react" const TagOperationParameters = dynamic( - async () => import("../.."), + async () => import("@/components/Tags/Operation/Parameters"), { loading: () => , } @@ -18,7 +19,7 @@ const TagOperationParameters = dynamic( const TagsOperationParametersNested = dynamic( - async () => import("../../Nested"), + async () => import("@/components/Tags/Operation/Parameters/Nested"), { loading: () => , } @@ -89,8 +90,10 @@ const TagOperationParametersObject = ({ // sort properties to show required fields first const sortedProperties = Object.keys(properties).sort( (property1, property2) => { - properties[property1].isRequired = checkRequired(schema, property1) - properties[property2].isRequired = checkRequired(schema, property2) + properties[property1].isRequired = + properties[property1].isRequired || checkRequired(schema, property1) + properties[property2].isRequired = + properties[property2].isRequired || checkRequired(schema, property2) return properties[property1].isRequired && properties[property2].isRequired diff --git a/www/apps/api-reference/components/Tags/Operation/Parameters/Types/OneOf/__tests__/index.test.tsx b/www/apps/api-reference/components/Tags/Operation/Parameters/Types/OneOf/__tests__/index.test.tsx new file mode 100644 index 0000000000..1910ef7359 --- /dev/null +++ b/www/apps/api-reference/components/Tags/Operation/Parameters/Types/OneOf/__tests__/index.test.tsx @@ -0,0 +1,140 @@ +import React from "react" +import { cleanup, fireEvent, render, waitFor } from "@testing-library/react" +import { describe, test, expect, vi, beforeEach } from "vitest" +import type { OpenAPI } from "types" +import { TagOperationParametersDefaultProps } from "../../Default" +import { TagOperationParametersProps } from "../../.." + +// mock data +const mockSchema: OpenAPI.SchemaObject = { + title: "test-title", + oneOf: [ + { + type: "object", + properties: { + name: { type: "string", properties: {} }, + }, + }, + { + type: "string", + properties: {} + } + ], + properties: {} +} + +// mock components +vi.mock("@/components/Tags/Operation/Parameters/Types/Default", () => ({ + default: ({ schema }: TagOperationParametersDefaultProps) => ( +
{JSON.stringify(schema)}
+ ), +})) +vi.mock("@/components/Tags/Operation/Parameters/Nested", () => ({ + default: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), +})) +vi.mock("@/components/Tags/Operation/Parameters", () => ({ + default: ({ schemaObject }: TagOperationParametersProps) => ( +
{JSON.stringify(schemaObject)}
+ ), +})) +vi.mock("docs-ui", () => ({ + Details: ({ children, summaryElm }: { children: React.ReactNode, summaryElm: React.ReactNode }) => ( +
+ {summaryElm} + {children} +
+ ), + Loading: () =>
Loading...
, +})) + +import TagsOperationParametersTypesOneOf from ".." + +beforeEach(() => { + vi.clearAllMocks() + cleanup() +}) + +describe("rendering", () => { + test("does not render when schema is not an oneOf type", () => { + const modifiedSchema: OpenAPI.SchemaObject = { + type: "object", + properties: { + name: { type: "string", properties: {} }, + }, + } + const { container } = render() + expect(container).toBeEmptyDOMElement() + }) + + test("renders one of type schema not nested by default", async () => { + const { container } = render() + await waitFor(() => { + const defaultElement = container.querySelector("[data-testid='default']") + expect(defaultElement).not.toBeInTheDocument() + const nestedElement = container.querySelector("[data-testid='nested']") + expect(nestedElement).not.toBeInTheDocument() + const parametersElement = container.querySelector("[data-testid='parameters']") + expect(parametersElement).toBeInTheDocument() + expect(parametersElement).toHaveTextContent(JSON.stringify(mockSchema.oneOf![0])) + }) + }) + + test("renders nested one of type schema", async () => { + const { container } = render() + await waitFor(() => { + const defaultElement = container.querySelector("[data-testid='default']") + expect(defaultElement).toBeInTheDocument() + const nestedElement = container.querySelector("[data-testid='nested']") + expect(nestedElement).toBeInTheDocument() + const parametersElement = nestedElement!.querySelector("[data-testid='parameters']") + expect(parametersElement).toBeInTheDocument() + expect(parametersElement).toHaveTextContent(JSON.stringify(mockSchema.oneOf![0])) + }) + }) + + test("renders parameter name when provided and nested is enabled", async () => { + const modifiedSchema: OpenAPI.SchemaObject = { + ...mockSchema, + parameterName: "test-parameter-name", + } + const { container } = render() + await waitFor(() => { + const detailsElement = container.querySelector("[data-testid='details']") + expect(detailsElement).toBeInTheDocument() + const summaryElement = detailsElement!.querySelector("summary") + expect(summaryElement).toBeInTheDocument() + expect(summaryElement).toHaveTextContent(modifiedSchema.parameterName!) + }) + }) + + test("renders schema title when parameter name is not provided and nested is enabled", async () => { + const modifiedSchema: OpenAPI.SchemaObject = { + ...mockSchema, + parameterName: undefined, + } + const { container } = render() + await waitFor(() => { + const detailsElement = container.querySelector("[data-testid='details']") + expect(detailsElement).toBeInTheDocument() + const summaryElement = detailsElement!.querySelector("summary") + expect(summaryElement).toBeInTheDocument() + expect(summaryElement).toHaveTextContent(modifiedSchema.title!) + }) + }) +}) + +describe("interaction", () => { + test("toggles between one of options when clicking on the tab", async () => { + const { container } = render() + const tabElements = container.querySelectorAll("[data-testid='tab']") + expect(tabElements).toHaveLength(mockSchema.oneOf!.length) + fireEvent.click(tabElements[1]!) + await waitFor(() => { + const parametersElement = container.querySelector("[data-testid='parameters']") + expect(parametersElement).toBeInTheDocument() + expect(parametersElement).toHaveTextContent(JSON.stringify(mockSchema.oneOf![1])) + }) + }) +}) \ No newline at end of file diff --git a/www/apps/api-reference/components/Tags/Operation/Parameters/Types/OneOf/index.tsx b/www/apps/api-reference/components/Tags/Operation/Parameters/Types/OneOf/index.tsx index 3b201eade7..bc22271b77 100644 --- a/www/apps/api-reference/components/Tags/Operation/Parameters/Types/OneOf/index.tsx +++ b/www/apps/api-reference/components/Tags/Operation/Parameters/Types/OneOf/index.tsx @@ -1,7 +1,7 @@ +import React, { useState } from "react" import type { OpenAPI } from "types" import clsx from "clsx" import dynamic from "next/dynamic" -import { useState } from "react" import type { TagOperationParametersDefaultProps } from "../Default" import type { TagsOperationParametersNestedProps } from "../../Nested" import type { TagOperationParametersProps } from "../.." @@ -43,22 +43,15 @@ const TagOperationParamatersOneOf = ({ }: TagOperationParamatersOneOfProps) => { const [activeTab, setActiveTab] = useState(0) + if (!schema.oneOf) { + return null + } + const getName = (item: OpenAPI.SchemaObject): string => { if (item.title) { return item.title } - if (item.anyOf || item.allOf) { - // return the name of any of the items - const name = item.anyOf - ? item.anyOf.find((i) => i.title !== undefined)?.title - : item.allOf?.find((i) => i.title !== undefined)?.title - - if (name) { - return name - } - } - return item.type || "" } @@ -84,6 +77,7 @@ const TagOperationParamatersOneOf = ({ ] )} onClick={() => setActiveTab(index)} + data-testid={"tab"} > {getName(item)} @@ -91,14 +85,10 @@ const TagOperationParamatersOneOf = ({
- {schema.oneOf && ( - <> - - - )} + ) } diff --git a/www/apps/api-reference/components/Tags/Operation/Parameters/Types/Union/__tests__/index.test.tsx b/www/apps/api-reference/components/Tags/Operation/Parameters/Types/Union/__tests__/index.test.tsx new file mode 100644 index 0000000000..21739e7b0c --- /dev/null +++ b/www/apps/api-reference/components/Tags/Operation/Parameters/Types/Union/__tests__/index.test.tsx @@ -0,0 +1,155 @@ +import React from "react" +import { cleanup, render, waitFor } from "@testing-library/react" +import { describe, test, expect, vi, beforeEach } from "vitest" +import type { OpenAPI } from "types" +import { TagOperationParametersObjectProps } from "../../Object" +import { TagOperationParametersDefaultProps } from "../../Default" + +// mock data +const mockAnyOfSchema: OpenAPI.SchemaObject = { + anyOf: [ + { type: "object", properties: { + name: { type: "string", properties: {} }, + } }, + { type: "string", properties: {} }, + ], + properties: {} +} + +const mockAllOfSchema: OpenAPI.SchemaObject = { + allOf: [ + { type: "object", properties: { + name: { type: "string", properties: {} }, + } }, + { type: "string", properties: {} }, + ], + properties: {} +} + +// mock functions +const mockMergeAllOfTypes = vi.fn((schema: OpenAPI.SchemaObject) => schema.allOf?.[0]) + +// mock components +vi.mock("@/components/Tags/Operation/Parameters/Types/Object", () => ({ + default: ({ schema }: TagOperationParametersObjectProps) => ( +
{JSON.stringify(schema)}
+ ), +})) +vi.mock("@/components/Tags/Operation/Parameters/Types/Default", () => ({ + default: ({ schema }: TagOperationParametersDefaultProps) => ( +
{JSON.stringify(schema)}
+ ), +})) + +vi.mock("docs-ui", () => ({ + Loading: () =>
Loading...
, +})) + +vi.mock("@/utils/merge-all-of-types", () => ({ + default: (schema: OpenAPI.SchemaObject) => mockMergeAllOfTypes(schema), +})) + +import TagOperationParametersUnion from ".." + +beforeEach(() => { + vi.clearAllMocks() + cleanup() +}) + +describe("rendering", () => { + test("renders default when schema is not an anyOf or allOf type", async () => { + const modifiedSchema: OpenAPI.SchemaObject = { + type: "object", + properties: { + name: { type: "string", properties: {} }, + }, + } + const { container } = render() + await waitFor(() => { + const defaultElement = container.querySelector("[data-testid='default']") + expect(defaultElement).toBeInTheDocument() + expect(defaultElement).toHaveTextContent(JSON.stringify(modifiedSchema)) + }) + }) + + test("renders default when schema is any of and type is not object", async () => { + const modifiedSchema: OpenAPI.SchemaObject = { + anyOf: [ + { type: "string", properties: {} }, + ], + properties: {} + } + const { container } = render() + await waitFor(() => { + const defaultElement = container.querySelector("[data-testid='default']") + expect(defaultElement).toBeInTheDocument() + expect(defaultElement).toHaveTextContent(JSON.stringify(modifiedSchema)) + }) + }) + + test("renders default when schema is any of and type is object and properties is not defined", async () => { + const modifiedSchema: OpenAPI.SchemaObject = { + anyOf: [ + { type: "object", properties: {} }, + ], + properties: {} + } + const { container } = render() + await waitFor(() => { + const defaultElement = container.querySelector("[data-testid='default']") + expect(defaultElement).toBeInTheDocument() + expect(defaultElement).toHaveTextContent(JSON.stringify(modifiedSchema)) + }) + }) + + test("renders any of type schema", async () => { + const { container } = render() + await waitFor(() => { + const objectElement = container.querySelector("[data-testid='object']") + expect(objectElement).toBeInTheDocument() + expect(objectElement).toHaveTextContent(JSON.stringify(mockAnyOfSchema.anyOf![0])) + }) + }) + + test("renders all of type schema", async () => { + const { container } = render() + await waitFor(() => { + const objectElement = container.querySelector("[data-testid='object']") + expect(objectElement).toBeInTheDocument() + expect(objectElement).toHaveTextContent(JSON.stringify(mockAllOfSchema.allOf![0])) + }) + expect(mockMergeAllOfTypes).toHaveBeenCalledWith(mockAllOfSchema) + }) + + test("renders schema description from selected object schema when provided", async () => { + const modifiedSchema: OpenAPI.SchemaObject = { + anyOf: [ + { ...mockAllOfSchema.allOf![0], description: "test-description 1" }, + { ...mockAllOfSchema.allOf![1], description: "test-description 2" }, + ], + properties: {}, + } + const { container } = render() + await waitFor(() => { + const objectElement = container.querySelector("[data-testid='object']") + expect(objectElement).toBeInTheDocument() + expect(objectElement).toHaveAttribute("data-description", "test-description 1") + }) + }) + + test("renders schema description from any item having description when selected object doesn't have description", async () => { + const modifiedSchema: OpenAPI.SchemaObject = { + anyOf: [ + { ...mockAllOfSchema.allOf![0] }, + { ...mockAllOfSchema.allOf![1], description: "test-description" }, + ], + properties: {}, + } + const { container } = render() + await waitFor(() => { + const objectElement = container.querySelector("[data-testid='object']") + expect(objectElement).toBeInTheDocument() + expect(objectElement).toHaveAttribute("data-description", "test-description") + }) + }) +}) \ No newline at end of file diff --git a/www/apps/api-reference/components/Tags/Operation/Parameters/Types/Union/index.tsx b/www/apps/api-reference/components/Tags/Operation/Parameters/Types/Union/index.tsx index 0521e27d88..e04377645b 100644 --- a/www/apps/api-reference/components/Tags/Operation/Parameters/Types/Union/index.tsx +++ b/www/apps/api-reference/components/Tags/Operation/Parameters/Types/Union/index.tsx @@ -1,9 +1,10 @@ +import React from "react" import type { OpenAPI } from "types" import dynamic from "next/dynamic" import type { TagOperationParametersDefaultProps } from "../Default" import { TagOperationParametersObjectProps } from "../Object" import { Loading } from "docs-ui" -import mergeAllOfTypes from "../../../../../../utils/merge-all-of-types" +import mergeAllOfTypes from "@/utils/merge-all-of-types" const TagOperationParametersObject = dynamic( async () => import("../Object"), @@ -34,7 +35,12 @@ const TagOperationParametersUnion = ({ topLevel, }: TagOperationParametersUnionProps) => { const objectSchema = schema.anyOf - ? schema.anyOf.find((item) => item.type === "object" && item.properties) + ? schema.anyOf.find( + (item) => + item.type === "object" && + item.properties && + Object.keys(item.properties).length > 0 + ) : schema.allOf ? mergeAllOfTypes(schema) : undefined diff --git a/www/apps/api-reference/components/Tags/Operation/Parameters/__tests__/index.test.tsx b/www/apps/api-reference/components/Tags/Operation/Parameters/__tests__/index.test.tsx new file mode 100644 index 0000000000..1596aa7000 --- /dev/null +++ b/www/apps/api-reference/components/Tags/Operation/Parameters/__tests__/index.test.tsx @@ -0,0 +1,293 @@ +import React from "react" +import { cleanup, render, waitFor } from "@testing-library/react" +import { describe, test, expect, vi, beforeEach } from "vitest" +import type { OpenAPI } from "types" +import { TagOperationParametersUnionProps } from "../Types/Union" +import { TagOperationParametersArrayProps } from "../Types/Array" +import { TagOperationParamatersOneOfProps } from "../Types/OneOf" +import { TagOperationParametersObjectProps } from "../Types/Object" +import { TagOperationParametersDefaultProps } from "../Types/Default" + +// mock functions +const mockCheckRequired = vi.fn() + +// mock components +vi.mock("@/components/Tags/Operation/Parameters/Types/Union", () => ({ + default: ({ schema, isRequired, name }: TagOperationParametersUnionProps) => ( +
{JSON.stringify(schema)}
+ ), +})) +vi.mock("@/components/Tags/Operation/Parameters/Types/OneOf", () => ({ + default: ({ schema, isRequired }: TagOperationParamatersOneOfProps) => ( +
{JSON.stringify(schema)}
+ ), +})) +vi.mock("@/components/Tags/Operation/Parameters/Types/Array", () => ({ + default: ({ schema, isRequired, name }: TagOperationParametersArrayProps) => ( +
{JSON.stringify(schema)}
+ ), +})) +vi.mock("@/components/Tags/Operation/Parameters/Types/Object", () => ({ + default: ({ schema, isRequired, name }: TagOperationParametersObjectProps) => ( +
{JSON.stringify(schema)}
+ ), +})) +vi.mock("@/components/Tags/Operation/Parameters/Types/Default", () => ({ + default: ({ schema, isRequired, name }: TagOperationParametersDefaultProps) => ( +
{JSON.stringify(schema)}
+ ), +})) +vi.mock("@/utils/check-required", () => ({ + default: (schema: OpenAPI.SchemaObject) => mockCheckRequired(schema), +})) +vi.mock("docs-utils", () => ({ + Loading: () =>
Loading...
, +})) + +import TagOperationParameters from ".." + +beforeEach(() => { + vi.clearAllMocks() + cleanup() +}) + +describe("rendering", () => { + test("renders union when schema is anyOf", async () => { + const modifiedSchema: OpenAPI.SchemaObject = { + anyOf: [ + { type: "object", properties: {} }, + { type: "string", properties: {} }, + ], + properties: {}, + } + const { container } = render() + await waitFor(() => { + const unionElement = container.querySelector("[data-testid='union']") + expect(unionElement).toBeInTheDocument() + expect(unionElement).toHaveTextContent(JSON.stringify(modifiedSchema)) + }) + }) + + test("renders union when schema is allOf", async () => { + const modifiedSchema: OpenAPI.SchemaObject = { + allOf: [ + { type: "object", properties: {} }, + { type: "string", properties: {} }, + ], + properties: {}, + } + const { container } = render() + await waitFor(() => { + const unionElement = container.querySelector("[data-testid='union']") + expect(unionElement).toBeInTheDocument() + expect(unionElement).toHaveTextContent(JSON.stringify(modifiedSchema)) + }) + expect(container.querySelector("[data-testid='object']")).not.toBeInTheDocument() + expect(container.querySelector("[data-testid='array']")).not.toBeInTheDocument() + expect(container.querySelector("[data-testid='default']")).not.toBeInTheDocument() + expect(container.querySelector("[data-testid='one-of']")).not.toBeInTheDocument() + }) + + test("renders oneOf when schema is oneOf", async () => { + const modifiedSchema: OpenAPI.SchemaObject = { + oneOf: [ + { type: "object", properties: {} }, + { type: "string", properties: {} }, + ], + properties: {}, + } + const { container } = render() + await waitFor(() => { + const oneOfElement = container.querySelector("[data-testid='one-of']") + expect(oneOfElement).toBeInTheDocument() + expect(oneOfElement).toHaveTextContent(JSON.stringify(modifiedSchema)) + }) + expect(container.querySelector("[data-testid='union']")).not.toBeInTheDocument() + expect(container.querySelector("[data-testid='object']")).not.toBeInTheDocument() + expect(container.querySelector("[data-testid='array']")).not.toBeInTheDocument() + expect(container.querySelector("[data-testid='default']")).not.toBeInTheDocument() + }) + + test("renders array when schema is array", async () => { + const modifiedSchema: OpenAPI.SchemaObject = { + type: "array", + items: { type: "string", properties: {} }, + properties: {}, + } + const { container } = render() + await waitFor(() => { + const arrayElement = container.querySelector("[data-testid='array']") + expect(arrayElement).toBeInTheDocument() + expect(arrayElement).toHaveTextContent(JSON.stringify(modifiedSchema)) + }) + expect(container.querySelector("[data-testid='union']")).not.toBeInTheDocument() + expect(container.querySelector("[data-testid='object']")).not.toBeInTheDocument() + expect(container.querySelector("[data-testid='one-of']")).not.toBeInTheDocument() + expect(container.querySelector("[data-testid='default']")).not.toBeInTheDocument() + }) + + test("renders object when schema is object", async () => { + const modifiedSchema: OpenAPI.SchemaObject = { + type: "object", + properties: { + name: { type: "string", properties: {} }, + }, + } + const { container } = render() + await waitFor(() => { + const objectElement = container.querySelector("[data-testid='object']") + expect(objectElement).toBeInTheDocument() + expect(objectElement).toHaveTextContent(JSON.stringify(modifiedSchema)) + }) + expect(container.querySelector("[data-testid='union']")).not.toBeInTheDocument() + expect(container.querySelector("[data-testid='one-of']")).not.toBeInTheDocument() + expect(container.querySelector("[data-testid='array']")).not.toBeInTheDocument() + expect(container.querySelector("[data-testid='default']")).not.toBeInTheDocument() + }) + + test("renders object when schema's type is undefined", async () => { + const modifiedSchema: OpenAPI.SchemaObject = { + type: undefined, + properties: {} + } + const { container } = render() + await waitFor(() => { + const objectElement = container.querySelector("[data-testid='object']") + expect(objectElement).toBeInTheDocument() + expect(objectElement).toHaveTextContent(JSON.stringify(modifiedSchema)) + }) + expect(container.querySelector("[data-testid='union']")).not.toBeInTheDocument() + expect(container.querySelector("[data-testid='one-of']")).not.toBeInTheDocument() + expect(container.querySelector("[data-testid='array']")).not.toBeInTheDocument() + expect(container.querySelector("[data-testid='default']")).not.toBeInTheDocument() + }) + + test("renders default when schema is not an anyOf, allOf, oneOf, array, or object", async () => { + const modifiedSchema: OpenAPI.SchemaObject = { + type: "string", + properties: {}, + } + const { container } = render() + await waitFor(() => { + const defaultElement = container.querySelector("[data-testid='default']") + expect(defaultElement).toBeInTheDocument() + expect(defaultElement).toHaveTextContent(JSON.stringify(modifiedSchema)) + }) + expect(container.querySelector("[data-testid='union']")).not.toBeInTheDocument() + expect(container.querySelector("[data-testid='one-of']")).not.toBeInTheDocument() + expect(container.querySelector("[data-testid='array']")).not.toBeInTheDocument() + expect(container.querySelector("[data-testid='object']")).not.toBeInTheDocument() + }) + + test("adds className when provided", async () => { + const modifiedSchema: OpenAPI.SchemaObject = { + type: "string", + properties: {}, + } + const { container } = render() + const parametersElement = container.querySelector("[data-testid='parameters']") + expect(parametersElement).toBeInTheDocument() + expect(parametersElement).toHaveClass("test-class") + }) +}) + +describe("isRequired", () => { + test("sets parameter as required when isRequired is true", async () => { + const modifiedSchema: OpenAPI.SchemaObject = { + type: "string", + properties: {}, + } + const { container } = render() + await waitFor(() => { + const defaultElement = container.querySelector("[data-testid='default']") + expect(defaultElement).toBeInTheDocument() + expect(defaultElement).toHaveAttribute("data-required", "true") + }) + }) + + test("sets parameter as required when isRequired is false and checkRequired returns true", async () => { + const modifiedSchema: OpenAPI.SchemaObject = { + type: "string", + properties: {}, + } + mockCheckRequired.mockReturnValue(true) + const { container } = render() + await waitFor(() => { + const defaultElement = container.querySelector("[data-testid='default']") + expect(defaultElement).toBeInTheDocument() + expect(defaultElement).toHaveAttribute("data-required", "true") + }) + }) + + test("sets parameter as optional when isRequired is false and checkRequired returns false", async () => { + const modifiedSchema: OpenAPI.SchemaObject = { + type: "string", + properties: {}, + } + mockCheckRequired.mockReturnValue(false) + const { container } = render() + await waitFor(() => { + const defaultElement = container.querySelector("[data-testid='default']") + expect(defaultElement).toBeInTheDocument() + expect(defaultElement).toHaveAttribute("data-required", "false") + }) + }) +}) + +describe("parameterName", () => { + test("sets parameter name to parameterName when provided", async () => { + const modifiedSchema: OpenAPI.SchemaObject = { + type: "string", + properties: {}, + parameterName: "test-name", + } + const { container } = render() + await waitFor(() => { + const defaultElement = container.querySelector("[data-testid='default']") + expect(defaultElement).toBeInTheDocument() + expect(defaultElement).toHaveAttribute("data-name", "test-name") + }) + }) + + test("sets parameter name to title when parameterName is not provided", async () => { + const modifiedSchema: OpenAPI.SchemaObject = { + type: "string", + properties: {}, + title: "test-title", + } + const { container } = render() + await waitFor(() => { + const defaultElement = container.querySelector("[data-testid='default']") + expect(defaultElement).toBeInTheDocument() + expect(defaultElement).toHaveAttribute("data-name", "test-title") + }) + }) + + test("sets parameter name to empty string when parameterName is not provided and title is not provided", async () => { + const modifiedSchema: OpenAPI.SchemaObject = { + type: "string", + properties: {}, + } + const { container } = render() + await waitFor(() => { + const defaultElement = container.querySelector("[data-testid='default']") + expect(defaultElement).toBeInTheDocument() + expect(defaultElement).toHaveAttribute("data-name", "") + }) + }) + + test("gives precedence to parameterName over title", async () => { + const modifiedSchema: OpenAPI.SchemaObject = { + type: "string", + properties: {}, + parameterName: "test-name", + title: "test-title", + } + const { container } = render() + await waitFor(() => { + const defaultElement = container.querySelector("[data-testid='default']") + expect(defaultElement).toBeInTheDocument() + expect(defaultElement).toHaveAttribute("data-name", "test-name") + }) + }) +}) \ No newline at end of file diff --git a/www/apps/api-reference/components/Tags/Operation/Parameters/index.tsx b/www/apps/api-reference/components/Tags/Operation/Parameters/index.tsx index 33dac38847..64b7dc1cfe 100644 --- a/www/apps/api-reference/components/Tags/Operation/Parameters/index.tsx +++ b/www/apps/api-reference/components/Tags/Operation/Parameters/index.tsx @@ -1,3 +1,4 @@ +import React from "react" import type { OpenAPI } from "types" import dynamic from "next/dynamic" import type { TagOperationParametersObjectProps } from "./Types/Object" @@ -109,11 +110,13 @@ const TagOperationParameters = ({ isRequired={isRequired} /> ) - - return <> } - return
{getElement()}
+ return ( +
+ {getElement()} +
+ ) } export default TagOperationParameters diff --git a/www/apps/api-reference/components/Tags/Operation/__tests__/index.test.tsx b/www/apps/api-reference/components/Tags/Operation/__tests__/index.test.tsx new file mode 100644 index 0000000000..0ca075fd7b --- /dev/null +++ b/www/apps/api-reference/components/Tags/Operation/__tests__/index.test.tsx @@ -0,0 +1,489 @@ +import React, { useState } from "react" +import { beforeEach, describe, expect, test, vi } from "vitest" +import { cleanup, render, waitFor, act, fireEvent } from "@testing-library/react" +import { OpenAPI } from "types" + +// mock data +const mockOperation: OpenAPI.Operation = { + operationId: "mockOperation", + summary: "Mock Operation", + description: "Mock Operation", + "x-authenticated": false, + "x-codeSamples": [ + { + label: "Request Sample 1", + lang: "javascript", + source: "console.log('Request Sample 1')", + } + ], + requestBody: { + content: {}, + }, + parameters: [], + responses: { + "200": { + description: "OK", + content: { + "application/json": { + schema: { + type: "object", + properties: { + name: { type: "string", properties: {} }, + }, + }, + }, + }, + }, + }, +} + +const mockTag: OpenAPI.OpenAPIV3.TagObject = { + name: "mock-tag", + description: "Mock Tag", +} +const mockEndpointPath = "/mock-endpoint-path" + +// mock functions +const mockIsElmWindow = vi.fn(() => false) +const mockUseIsBrowser = vi.fn(() => ({ + isBrowser: true, +})) +const mockScrollToTop = vi.fn() +const mockUseScrollController = vi.fn(() => ({ + scrollableElement: null as HTMLElement | null, + scrollToTop: mockScrollToTop, +})) +const mockSetActivePath = vi.fn() +const mockUseSidebar = vi.fn(() => ({ + activePath: null as string | null, + setActivePath: mockSetActivePath, +})) +const mockRemoveLoading = vi.fn() +const mockUseLoading = vi.fn(() => ({ + loading: false, + removeLoading: mockRemoveLoading, +})) +const mockPush = vi.fn() +const mockReplace = vi.fn() +const mockUseRouter = vi.fn(() => ({ + push: mockPush, + replace: mockReplace, +})) +const mockGetSectionId = vi.fn((options: unknown) => "mock-section-id") +const mockCheckElementInViewport = vi.fn(() => true) + + +// mock components +vi.mock("react-intersection-observer", () => ({ + InView: ({ children, onChange, root }: { children: React.ReactNode, onChange: (inView: boolean) => void, root: HTMLElement | null }) => { + const [inView, setInView] = useState(false) + return ( +
+ {children} + +
+ ) + }, +})) +vi.mock("docs-ui", () => ({ + isElmWindow: () => mockIsElmWindow(), + useIsBrowser: () => mockUseIsBrowser(), + useScrollController: () => mockUseScrollController(), + useSidebar: () => mockUseSidebar(), +})) +vi.mock("@/components/Tags/Operation/CodeSection", () => ({ + default: ({ operation, method }: { operation: OpenAPI.Operation, method?: string }) => ( +
{JSON.stringify(operation)}
+ ), +})) +vi.mock("@/components/Tags/Operation/DescriptionSection", () => ({ + default: ({ operation}: { operation: OpenAPI.Operation }) => ( +
{JSON.stringify(operation)}
+ ), +})) +vi.mock("@/layouts/Divided", () => ({ + default: ({ mainContent, codeContent }: { mainContent: React.ReactNode, codeContent: React.ReactNode }) => ( +
+
{mainContent}
+
{codeContent}
+
+ ), +})) +vi.mock("@/providers/loading", () => ({ + useLoading: () => mockUseLoading(), +})) +vi.mock("@/utils/check-element-in-viewport", () => ({ + default: () => mockCheckElementInViewport(), +})) +vi.mock("@/components/DividedLoading", () => ({ + default: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), +})) +vi.mock("@/components/Section/Container", () => ({ + default: React.forwardRef( + ({ children, className }, ref) => ( +
+ {children} +
+ ) + ), +})) +vi.mock("docs-utils", () => ({ + getSectionId: (options: unknown) => mockGetSectionId(options), +})) +vi.mock("next/navigation", () => ({ + useRouter: () => mockUseRouter(), +})) + +import TagOperation from ".." + +beforeEach(() => { + vi.clearAllMocks() + cleanup() + // Reset location hash + window.location.hash = "" +}) + +describe("rendering", () => { + test("renders InView wrapper with correct props", () => { + const { getByTestId } = render( + + ) + const inViewElement = getByTestId("in-view") + expect(inViewElement).toBeInTheDocument() + }) + + test("renders SectionContainer", () => { + const { getByTestId } = render( + + ) + const sectionContainerElement = getByTestId("section-container") + expect(sectionContainerElement).toBeInTheDocument() + }) + + test("renders with className", () => { + const { getByTestId } = render( + + ) + const sectionContainerElement = getByTestId("section-container") + expect(sectionContainerElement).toHaveClass("test-class") + }) + + test("renders loading component when show is false", () => { + const { getByTestId } = render( + + ) + const loadingElement = getByTestId("divided-loading") + expect(loadingElement).toBeInTheDocument() + }) + + test("renders loading component initially", () => { + const { getByTestId } = render( + + ) + const loadingElement = getByTestId("divided-loading") + expect(loadingElement).toBeInTheDocument() + }) +}) + +describe("path generation", () => { + test("generates path using getSectionId with tags and operationId", () => { + const operationWithTags: OpenAPI.Operation = { + ...mockOperation, + tags: ["tag1", "tag2"], + operationId: "test-operation", + } + render( + + ) + expect(mockGetSectionId).toHaveBeenCalledWith(["tag1", "tag2", "test-operation"]) + }) + + test("generates path using getSectionId with empty tags array when tags are not provided", () => { + const operationWithoutTags: OpenAPI.Operation = { + ...mockOperation, + tags: undefined, + operationId: "test-operation", + } + render( + + ) + expect(mockGetSectionId).toHaveBeenCalledWith(["test-operation"]) + }) +}) + +describe("hash matching and scrolling", () => { + test("removes loading when nodeRef is set", async () => { + render( + + ) + + expect(mockRemoveLoading).toHaveBeenCalled() + }) + + test("scrolls into view when hash matches path", async () => { + const mockPath = "mock-section-id" + mockGetSectionId.mockReturnValue(mockPath) + window.location.hash = `#${mockPath}` + + const { container } = render( + + ) + + expect(mockRemoveLoading).toHaveBeenCalled() + + await waitFor(() => { + const operationContainer = container.querySelector("[data-testid='operation-container']") + expect(operationContainer).toBeInTheDocument() + }) + }) + + test("sets show to true when hash prefix matches path prefix", async () => { + const mockPath = "mock-section-id_subsection" + mockGetSectionId.mockReturnValue(mockPath) + window.location.hash = "#mock-section-id" + + const { container } = render( + + ) + + expect(mockRemoveLoading).toHaveBeenCalled() + + await waitFor(() => { + const operationContainer = container.querySelector("[data-testid='operation-container']") + expect(operationContainer).toBeInTheDocument() + }) + }) +}) + +describe("InView behavior", () => { + test("sets active path when in view and activePath is different", () => { + const mockPath = "mock-section-id" + window.location.hash = "#different-hash" + mockGetSectionId.mockReturnValue(mockPath) + mockUseSidebar.mockReturnValue({ + activePath: "different-hash", + setActivePath: mockSetActivePath, + }) + + const { getByTestId } = render( + + ) + + const inViewToggleButton = getByTestId("in-view-toggle-button") + fireEvent.click(inViewToggleButton) + expect(mockSetActivePath).toHaveBeenCalledWith(mockPath) + }) + + test("updates router hash when in view and hash is different", () => { + const mockPath = "mock-section-id" + mockGetSectionId.mockReturnValue(mockPath) + window.location.hash = "#different-hash" + + const { getByTestId } = render( + + ) + + expect(getByTestId("in-view")).toBeInTheDocument() + const inViewToggleButton = getByTestId("in-view-toggle-button") + fireEvent.click(inViewToggleButton) + expect(mockPush).toHaveBeenCalledWith(`#${mockPath}`, { scroll: false }) + }) + + test("removes loading when in view and loading is true", () => { + mockUseLoading.mockReturnValue({ + loading: true, + removeLoading: mockRemoveLoading, + }) + + const { getByTestId } = render( + + ) + + expect(getByTestId("in-view")).toBeInTheDocument() + const inViewToggleButton = getByTestId("in-view-toggle-button") + fireEvent.click(inViewToggleButton) + expect(mockRemoveLoading).toHaveBeenCalled() + }) + + test("sets show to false when out of view and element is not in viewport", () => { + mockCheckElementInViewport.mockReturnValue(false) + + const { getByTestId } = render( + + ) + + expect(getByTestId("in-view")).toBeInTheDocument() + const inViewToggleButton = getByTestId("in-view-toggle-button") + // toggle it to true + fireEvent.click(inViewToggleButton) + // toggle it to false + fireEvent.click(inViewToggleButton) + // should show the divided loading now + expect(getByTestId("divided-loading")).toBeInTheDocument() + }) +}) + +describe("browser environment", () => { + test("handles non-browser environment", () => { + mockUseIsBrowser.mockReturnValue({ isBrowser: false }) + + const { getByTestId } = render( + + ) + + const inViewElement = getByTestId("in-view") + expect(inViewElement).toBeInTheDocument() + expect(inViewElement).not.toHaveAttribute("data-root") + }) + + test("uses document.body as root when scrollableElement is window and isBrowser is true", () => { + mockIsElmWindow.mockReturnValue(true) + mockUseScrollController.mockReturnValue({ + scrollableElement: window as unknown as HTMLElement, + scrollToTop: mockScrollToTop, + }) + mockUseIsBrowser.mockReturnValue({ isBrowser: true }) + + const { getByTestId } = render( + + ) + + const inViewElement = getByTestId("in-view") + expect(inViewElement).toBeInTheDocument() + expect(inViewElement).toHaveAttribute("data-root", "BODY") + }) + + test("uses scrollableElement as root when it is not window and isBrowser is true", () => { + const mockScrollableElement = document.createElement("div") + mockIsElmWindow.mockReturnValue(false) + mockUseIsBrowser.mockReturnValue({ isBrowser: true }) + mockUseScrollController.mockReturnValue({ + scrollableElement: mockScrollableElement, + scrollToTop: mockScrollToTop, + }) + + const { getByTestId } = render( + + ) + + const inViewElement = getByTestId("in-view") + expect(inViewElement).toBeInTheDocument() + expect(inViewElement).toHaveAttribute("data-root", "DIV") + }) +}) + +describe("method prop", () => { + test("renders component with method prop", async () => { + const { getByTestId } = render( + + ) + const inViewToggleButton = getByTestId("in-view-toggle-button") + // set show to true + fireEvent.click(inViewToggleButton) + await waitFor(() => { + const codeSectionElement = getByTestId("code-section") + expect(codeSectionElement).toBeInTheDocument() + expect(codeSectionElement).toHaveAttribute("data-method", "POST") + }) + }) + + test("renders component without method prop", async () => { + const { getByTestId } = render( + + ) + + const inViewToggleButton = getByTestId("in-view-toggle-button") + fireEvent.click(inViewToggleButton) + await waitFor(() => { + const codeSectionElement = getByTestId("code-section") + expect(codeSectionElement).toBeInTheDocument() + expect(codeSectionElement).toHaveAttribute("data-method", "") + }) + }) +}) + diff --git a/www/apps/api-reference/components/Tags/Operation/index.tsx b/www/apps/api-reference/components/Tags/Operation/index.tsx index 90bd9edabf..879f8a521d 100644 --- a/www/apps/api-reference/components/Tags/Operation/index.tsx +++ b/www/apps/api-reference/components/Tags/Operation/index.tsx @@ -1,5 +1,6 @@ "use client" +import React from "react" import type { OpenAPI } from "types" import clsx from "clsx" import { useCallback, useEffect, useMemo, useRef, useState } from "react" @@ -75,14 +76,16 @@ const TagOperation = ({ }, [nodeRef, isBrowser, scrollToTop]) useEffect(() => { - if (nodeRef && nodeRef.current) { - removeLoading() - const currentHash = location.hash.replace("#", "") - if (currentHash === path) { - setTimeout(scrollIntoView, 200) - } else if (currentHash.split("_")[0] === path.split("_")[0]) { - setShow(true) - } + if (!nodeRef.current) { + return + } + + removeLoading() + const currentHash = location.hash.replace("#", "") + if (currentHash === path) { + setTimeout(scrollIntoView, 200) + } else if (currentHash.split("_")[0] === path.split("_")[0]) { + setShow(true) } }, [nodeRef, path, scrollIntoView]) @@ -129,6 +132,7 @@ const TagOperation = ({ style={{ animationFillMode: "forwards", }} + data-testid="operation-container" > undefined as Sidebar.SidebarItem | undefined) +const mockAddItems = vi.fn() +const mockUseSidebar = vi.fn(() => ({ + shownSidebar: mockSidebar as Sidebar.Sidebar | Sidebar.SidebarItemSidebar | undefined, + addItems: mockAddItems, +})) +const mockUseLoading = vi.fn(() => ({ + loading: false, +})) +const mockGetTagChildSidebarItems = vi.fn(() => [] as Sidebar.SidebarItem[]) +const mockCompareOperations = vi.fn((options: unknown) => 0) + +// mock components and hooks +vi.mock("docs-ui", () => ({ + findSidebarItem: (options: unknown) => mockFindSidebarItem(options), + useSidebar: () => mockUseSidebar(), +})) +vi.mock("react", async () => { + const actual = await vi.importActual("react") + + return { + ...actual, + Suspense: ({ children }: { children: React.ReactNode }) => <>{children}, + } +}) +vi.mock("@/utils/get-tag-child-sidebar-items", () => ({ + default: () => mockGetTagChildSidebarItems(), +})) +vi.mock("@/providers/loading", () => ({ + useLoading: () => mockUseLoading(), +})) +vi.mock("@/components/DividedLoading", () => ({ + default: (className: string) =>
Loading...
, +})) +vi.mock("@/utils/sort-operations-utils", () => ({ + compareOperations: (options: unknown) => mockCompareOperations(options), +})) +vi.mock("@/components/Tags/Operation", () => ({ + default: (props: TagOperationProps) => ( +
Operation
+ ), +})) + +import TagPaths from ".." +import { TagOperationProps } from "../../Operation" + +beforeEach(() => { + vi.clearAllMocks() + cleanup() +}) + +describe("rendering", () => { + test("renders loading when loading is true", () => { + mockUseLoading.mockReturnValue({ loading: true }) + const { container } = render( + + ) + const dividedLoadingElement = container.querySelector("[data-testid='divided-loading']") + expect(dividedLoadingElement).toBeInTheDocument() + }) + + test("does not render loading when loading is false", () => { + mockUseLoading.mockReturnValue({ loading: false }) + const { container } = render( + + ) + const dividedLoadingElement = container.querySelector("[data-testid='divided-loading']") + expect(dividedLoadingElement).not.toBeInTheDocument() + }) + + test("renders operations", () => { + const { container } = render( + + ) + const operationElements = container.querySelectorAll("[data-testid='operation-container']") + expect(operationElements).toHaveLength(1) + expect(operationElements[0]).toHaveAttribute("data-method", "get") + expect(operationElements[0]).toHaveAttribute("data-endpoint-path", "/mock-path") + expect(operationElements[0]).toHaveAttribute("data-operation-id", "mockOperation") + }) + + test("renders operations in the correct order", () => { + // change the mockCompareOperations return value to 1 + mockCompareOperations.mockReturnValue(-1) + const modifiedMockPaths: OpenAPI.PathsObject = { + "/mock-path": { + get: { + ...mockOperation, + operationId: "mockOperation1", + }, + post: { + ...mockOperation, + operationId: "mockOperation2", + }, + }, + } + const { container } = render( + + ) + const operationElements = container.querySelectorAll("[data-testid='operation-container']") + expect(operationElements).toHaveLength(2) + expect(operationElements[0]).toHaveAttribute("data-operation-id", "mockOperation2") + expect(operationElements[1]).toHaveAttribute("data-operation-id", "mockOperation1") + }) +}) + +describe("sidebar", () => { + test("doesn't add items to sidebar when shownSidebar is not set", () => { + mockUseSidebar.mockReturnValue({ + shownSidebar: undefined, + addItems: mockAddItems, + }) + render( + + ) + expect(mockAddItems).not.toHaveBeenCalled() + }) + + test("doesn't add items to sidebar when paths is not set", () => { + mockUseSidebar.mockReturnValue({ + shownSidebar: mockSidebar, + addItems: mockAddItems, + }) + render( + + ) + expect(mockAddItems).not.toHaveBeenCalled() + }) + + test("adds items to sidebar when shownSidebar is set and paths is set and tag doesn't have an associated schema", () => { + mockUseSidebar.mockReturnValue({ + shownSidebar: { + ...mockSidebar, + items: [ + { + type: "category", + title: mockTag.name, + children: [], + } + ] + }, + addItems: mockAddItems, + }) + mockFindSidebarItem.mockReturnValue({ + type: "category", + title: mockTag.name, + children: [], + }) + const mockPathItems: Sidebar.SidebarItem[] = [ + { + type: "link", + title: "Mock Link", + path: "/mock-link", + children: [], + } + ] + mockGetTagChildSidebarItems.mockReturnValue(mockPathItems) + render( + + ) + expect(mockAddItems).toHaveBeenCalledWith(mockPathItems, { + sidebar_id: mockSidebar.sidebar_id, + // since there is no associated schema, the index position is 0 + indexPosition: 0, + parent: { + type: "category", + title: mockTag.name, + path: "", + changeLoaded: true, + }, + }) + }) + + test("adds items to sidebar when shownSidebar is set and paths is set and tag has an associated schema", () => { + mockUseSidebar.mockReturnValue({ + shownSidebar: { + ...mockSidebar, + items: [ + { + type: "category", + title: mockTag.name, + children: [], + } + ] + }, + addItems: mockAddItems, + }) + mockFindSidebarItem.mockReturnValue({ + type: "category", + title: mockTag.name, + children: [], + }) + const mockPathItems: Sidebar.SidebarItem[] = [ + { + type: "link", + title: "Mock Link", + path: "/mock-link", + children: [], + } + ] + mockGetTagChildSidebarItems.mockReturnValue(mockPathItems) + render( + + ) + expect(mockAddItems).toHaveBeenCalledWith(mockPathItems, { + sidebar_id: mockSidebar.sidebar_id, + indexPosition: 1, + parent: { + type: "category", + title: mockTag.name, + path: "", + changeLoaded: true, + }, + }) + }) + + test("doesn't add items to sidebar when parent item is not found", () => { + mockUseSidebar.mockReturnValue({ + shownSidebar: mockSidebar, + addItems: mockAddItems, + }) + mockFindSidebarItem.mockReturnValue(undefined) + render( + + ) + expect(mockAddItems).not.toHaveBeenCalled() + }) + + test("doesn't add items to sidebar when parent item has enough children and tag doesn't have an associated schema", () => { + mockUseSidebar.mockReturnValue({ + shownSidebar: mockSidebar, + addItems: mockAddItems, + }) + mockFindSidebarItem.mockReturnValue({ + type: "category", + title: mockTag.name, + children: [{ type: "link", title: "Mock Link", path: "/mock-link", children: [] }], + }) + render( + + ) + expect(mockAddItems).not.toHaveBeenCalled() + }) + + test("adds item to sidebar when parent item has an item and tag has an associated schema", () => { + mockUseSidebar.mockReturnValue({ + shownSidebar: mockSidebar, + addItems: mockAddItems, + }) + mockFindSidebarItem.mockReturnValue({ + type: "category", + title: mockTag.name, + children: [{ type: "link", title: "Mock Schema", path: "/mock-schema", children: [] }], + }) + render( + + ) + expect(mockAddItems).toHaveBeenCalled() + }) + + test("doesn't add items to sidebar when parent item has enough children and tag has an associated schema", () => { + mockUseSidebar.mockReturnValue({ + shownSidebar: mockSidebar, + addItems: mockAddItems, + }) + mockFindSidebarItem.mockReturnValue({ + type: "category", + title: mockTag.name, + children: [{ + type: "link", + title: "Mock Link", + path: "/mock-link", + children: [] + }, { + type: "link", + title: "Mock Link 2", + path: "/mock-link-2", + children: [] }], + }) + render( + + ) + expect(mockAddItems).not.toHaveBeenCalled() + }) +}) \ No newline at end of file diff --git a/www/apps/api-reference/components/Tags/Paths/index.tsx b/www/apps/api-reference/components/Tags/Paths/index.tsx index 45b0df6001..c105fa89fd 100644 --- a/www/apps/api-reference/components/Tags/Paths/index.tsx +++ b/www/apps/api-reference/components/Tags/Paths/index.tsx @@ -1,5 +1,6 @@ "use client" +import React from "react" import type { OpenAPI } from "types" import { findSidebarItem, useSidebar } from "docs-ui" import { Fragment, Suspense, useEffect, useMemo } from "react" @@ -10,7 +11,7 @@ import getTagChildSidebarItems from "@/utils/get-tag-child-sidebar-items" import { useLoading } from "@/providers/loading" import DividedLoading from "@/components/DividedLoading" import { Sidebar } from "types" -import { compareOperations } from "../../../utils/sort-operations-utils" +import { compareOperations } from "@/utils/sort-operations-utils" const TagOperation = dynamic( async () => import("../Operation") @@ -26,34 +27,31 @@ const TagPaths = ({ tag, className, paths }: TagPathsProps) => { const { loading } = useLoading() useEffect(() => { - if (!shownSidebar) { + if (!shownSidebar || !Object.keys(paths).length) { return } - if (paths) { - const parentItem = findSidebarItem({ - sidebarItems: - "items" in shownSidebar - ? shownSidebar.items - : shownSidebar.children || [], - item: { title: tag.name, type: "category" }, - checkChildren: false, - }) as Sidebar.SidebarItemCategory - const pathItems: Sidebar.SidebarItem[] = getTagChildSidebarItems(paths) - const targetLength = - pathItems.length + (tag["x-associatedSchema"] ? 1 : 0) - if ((parentItem.children?.length || 0) < targetLength) { - addItems(pathItems, { - sidebar_id: shownSidebar.sidebar_id, - parent: { - type: "category", - title: tag.name, - path: "", - changeLoaded: true, - }, - indexPosition: tag["x-associatedSchema"] ? 1 : 0, - }) - } + const parentItem = findSidebarItem({ + sidebarItems: + "items" in shownSidebar + ? shownSidebar.items + : shownSidebar.children || [], + item: { title: tag.name, type: "category" }, + checkChildren: false, + }) as Sidebar.SidebarItemCategory | undefined + const pathItems: Sidebar.SidebarItem[] = getTagChildSidebarItems(paths) + const targetLength = pathItems.length + (tag["x-associatedSchema"] ? 1 : 0) + if (parentItem && (parentItem.children?.length || 0) < targetLength) { + addItems(pathItems, { + sidebar_id: shownSidebar.sidebar_id, + parent: { + type: "category", + title: tag.name, + path: "", + changeLoaded: true, + }, + indexPosition: tag["x-associatedSchema"] ? 1 : 0, + }) } // eslint-disable-next-line react-hooks/exhaustive-deps }, [paths, shownSidebar?.sidebar_id]) diff --git a/www/apps/api-reference/components/Tags/Section/RoutesSummary/__tests__/index.test.tsx b/www/apps/api-reference/components/Tags/Section/RoutesSummary/__tests__/index.test.tsx new file mode 100644 index 0000000000..df07df754f --- /dev/null +++ b/www/apps/api-reference/components/Tags/Section/RoutesSummary/__tests__/index.test.tsx @@ -0,0 +1,98 @@ +import React from "react" +import { beforeEach, describe, expect, test, vi } from "vitest" +import { cleanup, render } from "@testing-library/react" +import { OpenAPI, Sidebar } from "types" + +// mock data +const mockTagName = "mockTagName" +const mockOperation: OpenAPI.Operation = { + operationId: "mockOperation", + summary: "Mock Operation", + description: "Mock Operation", + "x-authenticated": false, + "x-codeSamples": [], + requestBody: { content: {} }, + parameters: [], + responses: { + "200": { + description: "OK", + content: { + "application/json": { + schema: { + type: "object", + properties: { name: { type: "string", properties: {} } } + } + } + } + } + } +} +const mockPaths: OpenAPI.PathsObject = { + "/mock-path": { + get: mockOperation, + }, +} + +// mock functions +const mockCompareOperations = vi.fn((options: unknown) => 0) +const mockGetSectionId = vi.fn((options: unknown) => "mock-section-id") + +// mock components and hooks +vi.mock("docs-utils", () => ({ + getSectionId: (options: unknown) => mockGetSectionId(options), +})) +vi.mock("@/utils/sort-operations-utils", () => ({ + compareOperations: (options: unknown) => mockCompareOperations(options), +})) + +import { RoutesSummary } from ".." + +beforeEach(() => { + vi.clearAllMocks() + cleanup() +}) + +describe("rendering", () => { + test("renders nothing when there are no operations", () => { + const { container } = render( + + ) + expect(container).toBeEmptyDOMElement() + }) + + test("renders operation", () => { + const { container } = render( + + ) + const operationLink = container.querySelector("[data-testid='link']") + expect(operationLink).toBeInTheDocument() + expect(operationLink).toHaveAttribute("href", "#mock-section-id") + expect(operationLink).toHaveTextContent("/mock-path") + }) + + test("renders operations in the correct order", () => { + const modifiedMockPaths: OpenAPI.PathsObject = { + "/mock-path": { + get: { + ...mockOperation, + operationId: "mockOperation1", + }, + post: { + ...mockOperation, + operationId: "mockOperation2", + }, + }, + } + mockCompareOperations.mockReturnValue(-1) + mockGetSectionId.mockImplementation( + (options: unknown) => (options as string[]).join("-") + ) + const { container } = render( + + ) + const operationLinks = container.querySelectorAll("[data-testid='link']") + expect(operationLinks).toHaveLength(2) + expect(operationLinks[0]).toHaveAttribute("href", "#mockTagName-mockOperation2") + expect(operationLinks[1]).toHaveAttribute("href", "#mockTagName-mockOperation1") + }) +}) \ No newline at end of file diff --git a/www/apps/api-reference/components/Tags/Section/RoutesSummary/index.tsx b/www/apps/api-reference/components/Tags/Section/RoutesSummary/index.tsx index dee3b0dfba..b61403b937 100644 --- a/www/apps/api-reference/components/Tags/Section/RoutesSummary/index.tsx +++ b/www/apps/api-reference/components/Tags/Section/RoutesSummary/index.tsx @@ -3,7 +3,7 @@ import { getSectionId } from "docs-utils" import Link from "next/link" import React, { useMemo } from "react" import { OpenAPI } from "types" -import { compareOperations } from "../../../../utils/sort-operations-utils" +import { compareOperations } from "@/utils/sort-operations-utils" type RoutesSummaryProps = { tagName: string @@ -85,6 +85,7 @@ export const RoutesSummary = ({ tagName, paths }: RoutesSummaryProps) => { {endpointPath} diff --git a/www/apps/api-reference/components/Tags/Section/Schema/__tests__/index.test.tsx b/www/apps/api-reference/components/Tags/Section/Schema/__tests__/index.test.tsx new file mode 100644 index 0000000000..b024651f58 --- /dev/null +++ b/www/apps/api-reference/components/Tags/Section/Schema/__tests__/index.test.tsx @@ -0,0 +1,592 @@ +import React, { useState } from "react" +import { beforeEach, describe, expect, test, vi } from "vitest" +import { cleanup, render, waitFor, fireEvent } from "@testing-library/react" +import { OpenAPI } from "types" + +// mock data +const mockSchema: OpenAPI.SchemaObject = { + type: "object", + properties: { + name: { type: "string", properties: {} }, + }, +} +const mockTagName = "mockTagName" + +// mock functions +const mockIsElmWindow = vi.fn(() => false) +const mockUseIsBrowser = vi.fn(() => ({ + isBrowser: true, +})) +const mockScrollToElement = vi.fn() +const mockUseScrollController = vi.fn(() => ({ + scrollableElement: null as HTMLElement | null, + scrollToElement: mockScrollToElement, +})) +const mockSetActivePath = vi.fn() +const mockUseSidebar = vi.fn(() => ({ + activePath: null as string | null, + setActivePath: mockSetActivePath, +})) +const mockUseArea = vi.fn(() => ({ + displayedArea: "Store", +})) +const mockUseSchemaExample = vi.fn((_options: unknown) => ({ + examples: [ + { + content: '{"name": "test"}', + }, + ], +})) +const mockGetSectionId = vi.fn((options: unknown) => "mock-schema-slug") +const mockCheckElementInViewport = vi.fn( + (_element: HTMLElement, _threshold: number) => true +) +const mockSingular = vi.fn((str: string) => str.replace(/s$/, "")) + +// mock components +vi.mock("react-intersection-observer", () => ({ + InView: ({ + children, + onChange, + root, + id, + }: { + children: React.ReactNode + onChange: (inView: boolean, entry: IntersectionObserverEntry) => void + root?: HTMLElement | null + id?: string + }) => { + const [inView, setInView] = useState(false) + const mockTarget = document.createElement("div") + const mockEntry = { + target: mockTarget, + boundingClientRect: mockTarget.getBoundingClientRect(), + intersectionRatio: 1, + intersectionRect: mockTarget.getBoundingClientRect(), + isIntersecting: true, + rootBounds: null, + time: Date.now(), + } as unknown as IntersectionObserverEntry + + return ( +
+ {children} + +
+ ) + }, +})) +vi.mock("docs-ui", () => ({ + isElmWindow: () => mockIsElmWindow(), + useIsBrowser: () => mockUseIsBrowser(), + useScrollController: () => mockUseScrollController(), + useSidebar: () => mockUseSidebar(), + CodeBlock: ({ + source, + lang, + title, + className, + style, + }: { + source: string + lang: string + title?: string + className?: string + style?: React.CSSProperties + }) => ( +
+ {source} +
+ ), + Note: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + Link: ({ + href, + children, + variant, + }: { + href: string + children: React.ReactNode + variant?: string + }) => ( +
+ {children} + + ), +})) +vi.mock("@/components/Tags/Operation/Parameters", () => ({ + default: ({ + schemaObject, + topLevel, + }: { + schemaObject: OpenAPI.SchemaObject + topLevel?: boolean + }) => ( +
+ {JSON.stringify(schemaObject)} +
+ ), +})) +vi.mock("@/layouts/Divided", () => ({ + default: ({ + mainContent, + codeContent, + }: { + mainContent: React.ReactNode + codeContent: React.ReactNode + }) => ( +
+
{mainContent}
+
{codeContent}
+
+ ), +})) +vi.mock("@/components/Section/Container", () => ({ + default: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), +})) +vi.mock("@/hooks/use-schema-example", () => ({ + default: (options: unknown) => mockUseSchemaExample(options), +})) +vi.mock("@/providers/area", () => ({ + useArea: () => mockUseArea(), +})) +vi.mock("@/utils/check-element-in-viewport", () => ({ + default: (element: HTMLElement, threshold: number) => + mockCheckElementInViewport(element, threshold), +})) +vi.mock("docs-utils", () => ({ + getSectionId: (options: unknown) => mockGetSectionId(options), +})) +vi.mock("pluralize", () => ({ + singular: (str: string) => mockSingular(str), +})) + +import TagSectionSchema from ".." + +beforeEach(() => { + vi.clearAllMocks() + cleanup() + window.location.hash = "" + // Reset mocks to default values + mockIsElmWindow.mockReturnValue(false) + mockUseIsBrowser.mockReturnValue({ isBrowser: true }) + mockUseScrollController.mockReturnValue({ + scrollableElement: null, + scrollToElement: mockScrollToElement, + }) + mockUseSidebar.mockReturnValue({ + activePath: null, + setActivePath: mockSetActivePath, + }) + mockUseArea.mockReturnValue({ + displayedArea: "Store", + }) + mockUseSchemaExample.mockReturnValue({ + examples: [ + { + content: '{"name": "test"}', + }, + ], + }) + mockGetSectionId.mockReturnValue("mock-schema-slug") + mockCheckElementInViewport.mockReturnValue(true) + mockSingular.mockImplementation((str: string) => str.replace(/s$/, "")) +}) + +describe("rendering", () => { + test("renders InView wrapper with correct props", () => { + const { getByTestId } = render( + + ) + const inViewElement = getByTestId("in-view") + expect(inViewElement).toBeInTheDocument() + expect(inViewElement).toHaveAttribute("id", "mock-schema-slug") + }) + + test("renders SectionContainer", () => { + const { getByTestId } = render( + + ) + const sectionContainerElement = getByTestId("section-container") + expect(sectionContainerElement).toBeInTheDocument() + }) + + test("renders DividedLayout with mainContent and codeContent", () => { + const { getByTestId } = render( + + ) + const dividedElement = getByTestId("divided") + expect(dividedElement).toBeInTheDocument() + expect(getByTestId("divided-main-content")).toBeInTheDocument() + expect(getByTestId("divided-code-content")).toBeInTheDocument() + }) + + test("renders formatted name in heading", () => { + mockSingular.mockReturnValue("mockTagNam") + const { container } = render( + + ) + const heading = container.querySelector("h2") + expect(heading).toBeInTheDocument() + expect(heading).toHaveTextContent("mockTagNam Object") + }) + + test("renders Note with displayedArea", () => { + mockUseArea.mockReturnValue({ + displayedArea: "Admin", + }) + const { getByTestId } = render( + + ) + const noteElement = getByTestId("note") + expect(noteElement).toBeInTheDocument() + expect(noteElement).toHaveTextContent("Admin") + }) + + test("renders Link to Commerce Modules Documentation", () => { + const { getByTestId } = render( + + ) + const linkElement = getByTestId("link") + expect(linkElement).toBeInTheDocument() + expect(linkElement).toHaveAttribute( + "href", + "https://docs.medusajs.com/resources/commerce-modules" + ) + expect(linkElement).toHaveTextContent("Commerce Modules Documentation") + }) + + test("renders Fields heading", () => { + const { container } = render( + + ) + const fieldsHeading = container.querySelector("h4") + expect(fieldsHeading).toBeInTheDocument() + expect(fieldsHeading).toHaveTextContent("Fields") + }) + + test("renders TagOperationParameters with schema and topLevel prop", () => { + const { getByTestId } = render( + + ) + const parametersElement = getByTestId("tag-operation-parameters") + expect(parametersElement).toBeInTheDocument() + expect(parametersElement).toHaveAttribute("data-top-level", "true") + expect(parametersElement).toHaveTextContent(JSON.stringify(mockSchema)) + }) + + test("renders CodeBlock when examples exist", async () => { + const { getByTestId } = render( + + ) + await waitFor(() => { + const codeBlockElement = getByTestId("code-block") + expect(codeBlockElement).toBeInTheDocument() + expect(codeBlockElement).toHaveAttribute("data-lang", "json") + expect(codeBlockElement).toHaveTextContent('{"name": "test"}') + }) + }) + + test("does not render CodeBlock when examples are empty", () => { + mockUseSchemaExample.mockReturnValue({ + examples: [], + }) + const { queryByTestId } = render( + + ) + const codeBlockElement = queryByTestId("code-block") + expect(codeBlockElement).not.toBeInTheDocument() + }) + + test("renders CodeBlock with formatted name as title", async () => { + mockSingular.mockReturnValue("mockTagNam") + const { getByTestId } = render( + + ) + await waitFor(() => { + const codeBlockElement = getByTestId("code-block") + expect(codeBlockElement).toHaveAttribute( + "data-title", + "The mockTagNam Object" + ) + }) + }) +}) + +describe("name formatting", () => { + test("formats tag name using singular", () => { + mockSingular.mockReturnValue("product") + render() + expect(mockSingular).toHaveBeenCalledWith("products") + }) + + test("removes spaces from formatted name", () => { + mockSingular.mockReturnValue("test tag") + const { container } = render( + + ) + const heading = container.querySelector("h2") + expect(heading).toHaveTextContent("testtag Object") + }) +}) + +describe("schema slug generation", () => { + test("generates schema slug using getSectionId with tagName, formattedName, and 'schema'", () => { + mockSingular.mockReturnValue("mockTagNam") + render() + expect(mockGetSectionId).toHaveBeenCalledWith([ + mockTagName, + "mockTagNam", + "schema", + ]) + }) +}) + +describe("useEffect scrolling behavior", () => { + test("scrolls to element when hash matches schemaSlug and element is not in viewport", () => { + const mockPath = "mock-schema-slug" + mockGetSectionId.mockReturnValue(mockPath) + window.location.hash = `#${mockPath}` + mockUseSidebar.mockReturnValue({ + activePath: mockPath, + setActivePath: mockSetActivePath, + }) + mockCheckElementInViewport.mockReturnValue(false) + + // Create a mock element with the id + const mockElement = document.createElement("div") + mockElement.id = mockPath + document.body.appendChild(mockElement) + + render() + + expect(mockScrollToElement).toHaveBeenCalledWith(mockElement) + + document.body.removeChild(mockElement) + }) + + test("does not scroll when element is already in viewport", () => { + const mockPath = "mock-schema-slug" + mockGetSectionId.mockReturnValue(mockPath) + window.location.hash = `#${mockPath}` + mockUseSidebar.mockReturnValue({ + activePath: mockPath, + setActivePath: mockSetActivePath, + }) + mockCheckElementInViewport.mockReturnValue(true) + + const mockElement = document.createElement("div") + mockElement.id = mockPath + document.body.appendChild(mockElement) + + render() + + expect(mockScrollToElement).not.toHaveBeenCalled() + + document.body.removeChild(mockElement) + }) + + test("does not scroll when hash does not match schemaSlug", () => { + const mockPath = "mock-schema-slug" + mockGetSectionId.mockReturnValue(mockPath) + window.location.hash = "#different-hash" + mockUseSidebar.mockReturnValue({ + activePath: "different-hash", + setActivePath: mockSetActivePath, + }) + + render() + + expect(mockScrollToElement).not.toHaveBeenCalled() + }) + + test("scrolls when activePath matches but hash does not", () => { + const mockPath = "mock-schema-slug" + mockGetSectionId.mockReturnValue(mockPath) + window.location.hash = "#different-hash" + mockUseSidebar.mockReturnValue({ + activePath: mockPath, + setActivePath: mockSetActivePath, + }) + mockCheckElementInViewport.mockReturnValue(false) + + // Create a mock element with the id + const mockElement = document.createElement("div") + mockElement.id = mockPath + document.body.appendChild(mockElement) + + render() + + expect(mockScrollToElement).toHaveBeenCalledWith(mockElement) + + document.body.removeChild(mockElement) + }) + + test("does not scroll when not in browser", () => { + mockUseIsBrowser.mockReturnValue({ isBrowser: false }) + const mockPath = "mock-schema-slug" + mockGetSectionId.mockReturnValue(mockPath) + window.location.hash = `#${mockPath}` + mockUseSidebar.mockReturnValue({ + activePath: mockPath, + setActivePath: mockSetActivePath, + }) + + render() + + expect(mockScrollToElement).not.toHaveBeenCalled() + }) +}) + +describe("handleViewChange behavior", () => { + test("updates URL and active path when in view and activePath is different", () => { + const mockPath = "mock-schema-slug" + mockGetSectionId.mockReturnValue(mockPath) + mockUseSidebar.mockReturnValue({ + activePath: "different-path", + setActivePath: mockSetActivePath, + }) + mockCheckElementInViewport.mockReturnValue(true) + + const { getByTestId } = render( + + ) + + const inViewToggleButton = getByTestId("in-view-toggle-button") + fireEvent.click(inViewToggleButton) + + expect(mockSetActivePath).toHaveBeenCalledWith(mockPath) + expect(window.location.hash).toBe(`#${mockPath}`) + }) + + test("does not update when activePath already matches schemaSlug", () => { + const mockPath = "mock-schema-slug" + mockGetSectionId.mockReturnValue(mockPath) + mockUseSidebar.mockReturnValue({ + activePath: mockPath, + setActivePath: mockSetActivePath, + }) + mockCheckElementInViewport.mockReturnValue(true) + + const { getByTestId } = render( + + ) + + const inViewToggleButton = getByTestId("in-view-toggle-button") + mockSetActivePath.mockClear() + fireEvent.click(inViewToggleButton) + + // Should not be called because activePath === schemaSlug, so the condition fails + expect(mockSetActivePath).not.toHaveBeenCalled() + }) + + test("updates when element is in viewport even if not in view", () => { + const mockPath = "mock-schema-slug" + mockGetSectionId.mockReturnValue(mockPath) + mockUseSidebar.mockReturnValue({ + activePath: "different-path", + setActivePath: mockSetActivePath, + }) + mockCheckElementInViewport.mockReturnValue(true) + + const { getByTestId } = render( + + ) + + const inViewToggleButton = getByTestId("in-view-toggle-button") + // Click to set inView to false, but checkElementInViewport returns true + fireEvent.click(inViewToggleButton) + + expect(mockSetActivePath).toHaveBeenCalledWith(mockPath) + }) + + test("does not update when not in browser", () => { + mockUseIsBrowser.mockReturnValue({ isBrowser: false }) + const mockPath = "mock-schema-slug" + mockGetSectionId.mockReturnValue(mockPath) + mockUseSidebar.mockReturnValue({ + activePath: "different-path", + setActivePath: mockSetActivePath, + }) + + const { getByTestId } = render( + + ) + + const inViewToggleButton = getByTestId("in-view-toggle-button") + fireEvent.click(inViewToggleButton) + + expect(mockSetActivePath).not.toHaveBeenCalled() + }) +}) + +describe("browser environment", () => { + test("handles non-browser environment", () => { + mockUseIsBrowser.mockReturnValue({ isBrowser: false }) + + const { getByTestId } = render( + + ) + + const inViewElement = getByTestId("in-view") + expect(inViewElement).toBeInTheDocument() + expect(inViewElement).not.toHaveAttribute("data-root") + }) + + test("uses document.body as root when scrollableElement is window", () => { + mockIsElmWindow.mockReturnValue(true) + mockUseScrollController.mockReturnValue({ + scrollableElement: window as unknown as HTMLElement, + scrollToElement: mockScrollToElement, + }) + mockUseIsBrowser.mockReturnValue({ isBrowser: true }) + + const { getByTestId } = render( + + ) + + const inViewElement = getByTestId("in-view") + expect(inViewElement).toBeInTheDocument() + expect(inViewElement).toHaveAttribute("data-root", "BODY") + }) + + test("uses scrollableElement as root when it is not window", () => { + const mockScrollableElement = document.createElement("div") + mockIsElmWindow.mockReturnValue(false) + mockUseIsBrowser.mockReturnValue({ isBrowser: true }) + mockUseScrollController.mockReturnValue({ + scrollableElement: mockScrollableElement, + scrollToElement: mockScrollToElement, + }) + + const { getByTestId } = render( + + ) + + const inViewElement = getByTestId("in-view") + expect(inViewElement).toBeInTheDocument() + expect(inViewElement).toHaveAttribute("data-root", "DIV") + }) +}) \ No newline at end of file diff --git a/www/apps/api-reference/components/Tags/Section/Schema/index.tsx b/www/apps/api-reference/components/Tags/Section/Schema/index.tsx index 38571de0bb..f5b028af8f 100644 --- a/www/apps/api-reference/components/Tags/Section/Schema/index.tsx +++ b/www/apps/api-reference/components/Tags/Section/Schema/index.tsx @@ -1,5 +1,6 @@ "use client" +import React from "react" import { Suspense, useEffect, useMemo } from "react" import { OpenAPI } from "types" import TagOperationParameters from "../../Operation/Parameters" @@ -28,7 +29,7 @@ export type TagSectionSchemaProps = { } const TagSectionSchema = ({ schema, tagName }: TagSectionSchemaProps) => { - const { setActivePath, activePath, shownSidebar, updateItems } = useSidebar() + const { setActivePath, activePath } = useSidebar() const { displayedArea } = useArea() const formattedName = useMemo( () => singular(tagName).replaceAll(" ", ""), diff --git a/www/apps/api-reference/components/Tags/Section/__tests__/index.test.tsx b/www/apps/api-reference/components/Tags/Section/__tests__/index.test.tsx new file mode 100644 index 0000000000..0f8556812b --- /dev/null +++ b/www/apps/api-reference/components/Tags/Section/__tests__/index.test.tsx @@ -0,0 +1,830 @@ +import React, { useState } from "react" +import { beforeEach, describe, expect, test, vi } from "vitest" +import { cleanup, render, waitFor, fireEvent } from "@testing-library/react" +import { OpenAPI } from "types" + +// mock data +const mockTag: OpenAPI.TagObject = { + name: "mockTag", + description: "Mock Tag Description", +} + +const mockTagWithExternalDocs: OpenAPI.TagObject = { + name: "mockTag", + description: "Mock Tag Description", + externalDocs: { + url: "https://example.com/guide", + description: "Read the guide", + }, +} + +const mockTagWithSchema: OpenAPI.TagObject = { + name: "mockTag", + description: "Mock Tag Description", + "x-associatedSchema": { + $ref: "#/components/schemas/MockSchema", + }, +} + +const mockSchemaData = { + schema: { + type: "object", + properties: { + name: { type: "string", properties: {} }, + }, + } as OpenAPI.SchemaObject, +} + +const mockPathsData = { + paths: { + "/mock-path": { + get: { + operationId: "mockOperation", + summary: "Mock Operation", + description: "Mock Operation", + "x-authenticated": false, + "x-codeSamples": [], + requestBody: { content: {} }, + parameters: [], + responses: { + "200": { + description: "OK", + content: { + "application/json": { + schema: { + type: "object", + properties: { + name: { type: "string", properties: {} }, + }, + }, + }, + }, + }, + }, + }, + }, + } as OpenAPI.PathsObject, +} + +// mock functions +const mockIsElmWindow = vi.fn(() => false) +const mockUseIsBrowser = vi.fn(() => ({ + isBrowser: true, +})) +const mockScrollToTop = vi.fn() +const mockUseScrollController = vi.fn(() => ({ + scrollableElement: null as HTMLElement | null, + scrollToTop: mockScrollToTop, +})) +const mockSetActivePath = vi.fn() +const mockUseSidebar = vi.fn(() => ({ + activePath: null as string | null, + setActivePath: mockSetActivePath, +})) +const mockUseArea = vi.fn(() => ({ + area: "store", +})) +const mockGetSectionId = vi.fn((options: unknown) => "mock-slug-tag-name") +const mockCheckElementInViewport = vi.fn( + (_element: HTMLElement, _threshold: number) => true +) +const mockBasePathUrl = vi.fn((url: string) => url) +const mockSwrFetcher = vi.fn() +const mockPush = vi.fn() +const mockReplace = vi.fn() +const mockUseRouter = vi.fn(() => ({ + push: mockPush, + replace: mockReplace, +})) +const mockUseSWR = vi.fn((key: string | null, fetcher: unknown, options: unknown) => ({ + data: undefined as {paths: OpenAPI.PathsObject} | {schema: OpenAPI.SchemaObject} | undefined, + error: undefined, + isLoading: false, +})) + +// mock components +vi.mock("react-intersection-observer", () => ({ + InView: ({ + children, + onChange, + root, + id, + className, + }: { + children: React.ReactNode + onChange: (inView: boolean) => void + root?: HTMLElement | null + id?: string + className?: string + }) => { + const [inView, setInView] = useState(false) + + return ( +
+ {children} + +
+ ) + }, +})) +vi.mock("docs-ui", () => ({ + isElmWindow: () => mockIsElmWindow(), + useIsBrowser: () => mockUseIsBrowser(), + useScrollController: () => mockUseScrollController(), + useSidebar: () => mockUseSidebar(), + H2: ({ children }: { children: React.ReactNode }) => ( +

{children}

+ ), + Link: ({ + href, + children, + target, + variant, + }: { + href: string + children: React.ReactNode + target?: string + variant?: string + }) => ( + + {children} + + ), + Loading: () =>
Loading...
, + swrFetcher: () => mockSwrFetcher(), +})) +vi.mock("@/providers/area", () => ({ + useArea: () => mockUseArea(), +})) +vi.mock("@/components/Section/Container", () => ({ + default: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), +})) +vi.mock("@/layouts/Divided", () => ({ + default: ({ + mainContent, + codeContent, + }: { + mainContent: React.ReactNode + codeContent: React.ReactNode + }) => ( +
+
{mainContent}
+
{codeContent}
+
+ ), +})) +vi.mock("@/components/Tags/Section/Schema", () => ({ + default: ({ + schema, + tagName, + }: { + schema: OpenAPI.SchemaObject + tagName: string + }) => ( +
+ {JSON.stringify(schema)} +
+ ), +})) +vi.mock("@/components/Tags/Paths", () => ({ + default: ({ + tag, + paths, + }: { + tag: OpenAPI.TagObject + paths: OpenAPI.PathsObject + }) => ( +
+ {JSON.stringify(paths)} +
+ ), +})) +vi.mock("@/components/Tags/Section/RoutesSummary", () => ({ + RoutesSummary: ({ + tagName, + paths, + }: { + tagName: string + paths: OpenAPI.PathsObject + }) => ( +
+ {JSON.stringify(paths)} +
+ ), +})) +vi.mock("@/components/Section/Divider", () => ({ + default: ({ className }: { className?: string }) => ( +
+ ), +})) +vi.mock("@/components/Feedback", () => ({ + Feedback: ({ + question, + extraData, + }: { + question: string + extraData: { section: string } + }) => ( +
+ {question} +
+ ), +})) +vi.mock("@/providers/loading", () => ({ + default: ({ + children, + initialLoading, + }: { + children: React.ReactNode + initialLoading?: boolean + }) => ( +
+ {children} +
+ ), +})) +vi.mock("@/components/MDXContent/Client", () => ({ + default: ({ + content, + scope, + }: { + content: string + scope: { addToSidebar: boolean } + }) => ( +
+ {content} +
+ ), +})) +vi.mock("@/components/Section/Divider", () => ({ + default: ({ className }: { className?: string }) => ( +
+ ), +})) +vi.mock("@/components/Section", () => ({ + default: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), +})) +vi.mock("next/navigation", () => ({ + useRouter: () => mockUseRouter(), +})) +vi.mock("docs-utils", () => ({ + getSectionId: (options: unknown) => mockGetSectionId(options), +})) +vi.mock("@/utils/check-element-in-viewport", () => ({ + default: (element: HTMLElement, threshold: number) => + mockCheckElementInViewport(element, threshold), +})) +vi.mock("@/utils/base-path-url", () => ({ + default: (url: string) => mockBasePathUrl(url), +})) +vi.mock("swr", () => ({ + default: (key: string | null, fetcher: unknown, options: unknown) => + mockUseSWR(key, fetcher, options), +})) + +import TagSectionComponent from ".." + +beforeEach(() => { + vi.clearAllMocks() + cleanup() + window.location.hash = "" + // Reset mocks to default values + mockIsElmWindow.mockReturnValue(false) + mockUseIsBrowser.mockReturnValue({ isBrowser: true }) + mockUseScrollController.mockReturnValue({ + scrollableElement: null, + scrollToTop: mockScrollToTop, + }) + mockUseSidebar.mockReturnValue({ + activePath: null, + setActivePath: mockSetActivePath, + }) + mockUseArea.mockReturnValue({ + area: "store", + }) + mockGetSectionId.mockReturnValue("mock-slug-tag-name") + mockCheckElementInViewport.mockReturnValue(true) + mockBasePathUrl.mockImplementation((url: string) => url) + mockUseSWR.mockReturnValue({ + data: undefined, + error: undefined, + isLoading: false, + }) +}) + +describe("rendering", () => { + test("renders InView wrapper with correct props", () => { + const { getByTestId } = render() + const inViewElement = getByTestId("in-view") + expect(inViewElement).toBeInTheDocument() + expect(inViewElement).toHaveAttribute("id", "mock-slug-tag-name") + expect(inViewElement).toHaveClass("min-h-screen", "relative") + }) + + test("renders SectionContainer", () => { + const { getByTestId } = render() + const sectionContainerElement = getByTestId("section-container") + expect(sectionContainerElement).toBeInTheDocument() + }) + + test("renders DividedLayout with mainContent and codeContent", () => { + const { getByTestId } = render() + const dividedElement = getByTestId("divided") + expect(dividedElement).toBeInTheDocument() + expect(getByTestId("divided-main-content")).toBeInTheDocument() + expect(getByTestId("divided-code-content")).toBeInTheDocument() + }) + + test("renders H2 with tag name", () => { + const { getByTestId } = render() + const h2Element = getByTestId("h2") + expect(h2Element).toBeInTheDocument() + expect(h2Element).toHaveTextContent(mockTag.name) + }) + + test("renders MDXContentClient when tag has description", async () => { + const { getByTestId } = render() + await waitFor(() => { + const mdxContentElement = getByTestId("mdx-content-client") + expect(mdxContentElement).toBeInTheDocument() + expect(mdxContentElement).toHaveTextContent(mockTag.description!) + expect(mdxContentElement).toHaveAttribute("data-add-to-sidebar", "false") + }) + }) + + test("does not render MDXContentClient when tag has no description", () => { + const tagWithoutDescription: OpenAPI.TagObject = { + name: "mockTag", + } + const { queryByTestId } = render( + + ) + const mdxContentElement = queryByTestId("mdx-content-client") + expect(mdxContentElement).not.toBeInTheDocument() + }) + + test("renders external docs link when tag has externalDocs", () => { + const { getByTestId } = render( + + ) + const linkElement = getByTestId("link") + expect(linkElement).toBeInTheDocument() + expect(linkElement).toHaveAttribute( + "href", + mockTagWithExternalDocs.externalDocs!.url + ) + expect(linkElement).toHaveAttribute("target", "_blank") + expect(linkElement).toHaveTextContent( + mockTagWithExternalDocs.externalDocs!.description! + ) + }) + + test("renders 'Read More' when externalDocs has no description", () => { + const tagWithExternalDocsNoDescription: OpenAPI.TagObject = { + name: "mockTag", + externalDocs: { + url: "https://example.com/guide", + }, + } + const { getByTestId } = render( + + ) + const linkElement = getByTestId("link") + expect(linkElement).toHaveTextContent("Read More") + }) + + test("does not render external docs link when tag has no externalDocs", () => { + const { queryByTestId } = render() + const linkElement = queryByTestId("link") + expect(linkElement).not.toBeInTheDocument() + }) + + test("renders Feedback component", () => { + const { getByTestId } = render() + const feedbackElement = getByTestId("feedback") + expect(feedbackElement).toBeInTheDocument() + expect(feedbackElement).toHaveAttribute("data-section", mockTag.name) + expect(feedbackElement).toHaveTextContent("Was this section helpful?") + }) + + test("renders RoutesSummary with empty paths initially", () => { + const { getByTestId } = render() + const routesSummaryElement = getByTestId("routes-summary") + expect(routesSummaryElement).toBeInTheDocument() + expect(routesSummaryElement).toHaveAttribute("data-tag-name", mockTag.name) + expect(routesSummaryElement).toHaveTextContent("{}") + }) + + test("renders SectionDivider when loadData is false", () => { + const { getByTestId } = render() + const dividerElement = getByTestId("section-divider") + expect(dividerElement).toBeInTheDocument() + expect(dividerElement).toHaveClass("lg:!-left-1") + }) + + test("does not render SectionDivider when loadData is true", () => { + mockUseSWR.mockReturnValue({ + data: { paths: mockPathsData }, + error: undefined, + isLoading: false, + }) + const { getByTestId, queryByTestId } = render( + + ) + const inViewToggleButton = getByTestId("in-view-toggle-button") + fireEvent.click(inViewToggleButton) + waitFor(() => { + const dividerElement = queryByTestId("section-divider") + expect(dividerElement).not.toBeInTheDocument() + }) + }) +}) + +describe("slug generation", () => { + test("generates slug using getSectionId with tag name", () => { + render() + expect(mockGetSectionId).toHaveBeenCalledWith([mockTag.name]) + }) +}) + +describe("useSWR hooks", () => { + test("does not fetch schema data when loadData is false", () => { + render() + expect(mockUseSWR).not.toHaveBeenCalledWith( + `/schema?name=${mockTagWithSchema["x-associatedSchema"]!.$ref}&area=store`, + ) + }) + + test("fetches schema data when loadData is true and tag has x-associatedSchema", () => { + mockUseSWR.mockReturnValue({ + data: mockSchemaData, + error: undefined, + isLoading: false, + }) + const { getByTestId } = render( + + ) + const inViewToggleButton = getByTestId("in-view-toggle-button") + fireEvent.click(inViewToggleButton) + expect(mockBasePathUrl).toHaveBeenCalledWith( + `/schema?name=${mockTagWithSchema["x-associatedSchema"]!.$ref}&area=store` + ) + }) + + test("does not fetch paths data when loadData is false", () => { + render() + expect(mockUseSWR).not.toHaveBeenCalledWith(`/tag?tagName=mock-slug-tag-name&area=store`) + }) + + test("fetches paths data when loadData is true", () => { + mockUseSWR.mockReturnValue({ + data: mockPathsData, + error: undefined, + isLoading: false, + }) + const { getByTestId } = render() + const inViewToggleButton = getByTestId("in-view-toggle-button") + fireEvent.click(inViewToggleButton) + expect(mockBasePathUrl).toHaveBeenCalledWith( + `/tag?tagName=mock-slug-tag-name&area=store` + ) + }) +}) + +describe("conditional rendering", () => { + test("renders TagSectionSchema when schemaData exists", async () => { + mockUseSWR.mockImplementation((key: string | null) => { + if (key?.includes("schema")) { + return { + data: mockSchemaData, + error: undefined, + isLoading: false, + } + } + return { + data: undefined, + error: undefined, + isLoading: false, + } + }) + const { getByTestId } = render( + + ) + const inViewToggleButton = getByTestId("in-view-toggle-button") + fireEvent.click(inViewToggleButton) + await waitFor(() => { + const schemaElement = getByTestId("tag-section-schema") + expect(schemaElement).toBeInTheDocument() + expect(schemaElement).toHaveAttribute("data-tag-name", mockTagWithSchema.name) + }) + }) + + test("does not render TagSectionSchema when schemaData does not exist", () => { + const { queryByTestId } = render() + const schemaElement = queryByTestId("tag-section-schema") + expect(schemaElement).not.toBeInTheDocument() + }) + + test("renders TagPaths when loadData is true and pathsData exists", async () => { + mockUseSWR.mockReturnValue({ + data: mockPathsData, + error: undefined, + isLoading: false, + }) + const { getByTestId } = render() + const inViewToggleButton = getByTestId("in-view-toggle-button") + fireEvent.click(inViewToggleButton) + await waitFor(() => { + const tagPathsElement = getByTestId("tag-paths") + expect(tagPathsElement).toBeInTheDocument() + expect(tagPathsElement).toHaveAttribute("data-tag-name", mockTag.name) + }) + }) + + test("does not render TagPaths when loadData is false", () => { + const { queryByTestId } = render() + const tagPathsElement = queryByTestId("tag-paths") + expect(tagPathsElement).not.toBeInTheDocument() + }) + + test("renders LoadingProvider with initialLoading when TagPaths is rendered", async () => { + mockUseSWR.mockReturnValue({ + data: mockPathsData, + error: undefined, + isLoading: false, + }) + const { getByTestId } = render() + const inViewToggleButton = getByTestId("in-view-toggle-button") + fireEvent.click(inViewToggleButton) + await waitFor(() => { + const loadingProviderElement = getByTestId("loading-provider") + expect(loadingProviderElement).toBeInTheDocument() + expect(loadingProviderElement).toHaveAttribute( + "data-initial-loading", + "true" + ) + }) + }) +}) + +describe("useEffect scrolling behavior", () => { + test("scrolls to element when activePath matches slugTagName and element is not in viewport", () => { + const mockPath = "mock-slug-tag-name" + mockGetSectionId.mockReturnValue(mockPath) + mockUseSidebar.mockReturnValue({ + activePath: mockPath, + setActivePath: mockSetActivePath, + }) + mockCheckElementInViewport.mockReturnValue(false) + + const mockElement = document.createElement("div") + mockElement.id = mockPath + Object.defineProperty(mockElement, "offsetTop", { value: 100 }) + Object.defineProperty(mockElement, "offsetParent", { value: { offsetTop: 50 } as HTMLElement }) + document.body.appendChild(mockElement) + + render() + + expect(mockScrollToTop).toHaveBeenCalledWith(150, 0) + + document.body.removeChild(mockElement) + }) + + test("does not scroll when element is already in viewport", () => { + const mockPath = "mock-slug-tag-name" + mockGetSectionId.mockReturnValue(mockPath) + mockUseSidebar.mockReturnValue({ + activePath: mockPath, + setActivePath: mockSetActivePath, + }) + mockCheckElementInViewport.mockReturnValue(true) + + const mockElement = document.createElement("div") + mockElement.id = mockPath + document.body.appendChild(mockElement) + + render() + + expect(mockScrollToTop).not.toHaveBeenCalled() + + document.body.removeChild(mockElement) + }) + + test("sets loadData to true when activePath has multiple parts", () => { + const mockPath = "mock-slug-tag-name_operation" + mockGetSectionId.mockReturnValue("mock-slug-tag-name") + mockUseSidebar.mockReturnValue({ + activePath: mockPath, + setActivePath: mockSetActivePath, + }) + + const { getByTestId } = render() + // After useEffect runs, loadData should be true, so divider should not be visible + waitFor(() => { + const dividerElement = getByTestId("section-divider") + expect(dividerElement).not.toBeInTheDocument() + }) + }) + + test("does not scroll when activePath does not include slugTagName", () => { + mockUseSidebar.mockReturnValue({ + activePath: "different-path", + setActivePath: mockSetActivePath, + }) + + render() + + expect(mockScrollToTop).not.toHaveBeenCalled() + }) + + test("does not scroll when activePath is null", () => { + mockUseSidebar.mockReturnValue({ + activePath: null, + setActivePath: mockSetActivePath, + }) + + render() + + expect(mockScrollToTop).not.toHaveBeenCalled() + }) + + test("does not scroll when not in browser", () => { + mockUseIsBrowser.mockReturnValue({ isBrowser: false }) + const mockPath = "mock-slug-tag-name" + mockGetSectionId.mockReturnValue(mockPath) + mockUseSidebar.mockReturnValue({ + activePath: mockPath, + setActivePath: mockSetActivePath, + }) + + render() + + expect(mockScrollToTop).not.toHaveBeenCalled() + }) +}) + +describe("InView onChange behavior", () => { + test("sets loadData to true when in view", () => { + const { getByTestId, queryByTestId } = render( + + ) + const inViewToggleButton = getByTestId("in-view-toggle-button") + fireEvent.click(inViewToggleButton) + // After loadData is set, divider should disappear + waitFor(() => { + const dividerElement = queryByTestId("section-divider") + expect(dividerElement).not.toBeInTheDocument() + }) + }) + + test("updates router hash when in view and hash does not match", () => { + window.location.hash = "#different-hash" + mockUseSidebar.mockReturnValue({ + activePath: "different-path", + setActivePath: mockSetActivePath, + }) + const { getByTestId } = render() + const inViewToggleButton = getByTestId("in-view-toggle-button") + fireEvent.click(inViewToggleButton) + + expect(mockPush).toHaveBeenCalledWith("#mock-slug-tag-name", { + scroll: false, + }) + }) + + test("sets active path when in view and activePath is different", () => { + mockUseSidebar.mockReturnValue({ + activePath: "different-path", + setActivePath: mockSetActivePath, + }) + window.location.hash = "#mock-slug-tag-name" + + const { getByTestId } = render() + const inViewToggleButton = getByTestId("in-view-toggle-button") + fireEvent.click(inViewToggleButton) + + expect(mockSetActivePath).toHaveBeenCalledWith("mock-slug-tag-name") + }) + + test("does not update hash when current hash links to inner path", () => { + window.location.hash = "#mock-slug-tag-name_operation" + const { getByTestId } = render() + const inViewToggleButton = getByTestId("in-view-toggle-button") + mockSetActivePath.mockClear() + fireEvent.click(inViewToggleButton) + + // Should not update because hash links to inner path + expect(mockSetActivePath).not.toHaveBeenCalledWith("mock-slug-tag-name") + }) + + test("does not update when hash already matches slugTagName", () => { + window.location.hash = "#mock-slug-tag-name" + mockUseSidebar.mockReturnValue({ + activePath: "mock-slug-tag-name", + setActivePath: mockSetActivePath, + }) + + const { getByTestId } = render() + const inViewToggleButton = getByTestId("in-view-toggle-button") + mockSetActivePath.mockClear() + fireEvent.click(inViewToggleButton) + + // Should not update because already matches + expect(mockSetActivePath).not.toHaveBeenCalled() + }) + + test("does not update when not in view", () => { + const { getByTestId } = render() + const inViewToggleButton = getByTestId("in-view-toggle-button") + // Click once to set inView to true, then click again to set to false + fireEvent.click(inViewToggleButton) + mockSetActivePath.mockClear() + fireEvent.click(inViewToggleButton) + + // Should not update when not in view + expect(mockSetActivePath).not.toHaveBeenCalled() + }) +}) + +describe("className behavior", () => { + test("applies 'relative' class when loadData is false", () => { + const { getByTestId } = render() + const inViewElement = getByTestId("in-view") + expect(inViewElement).toHaveClass("relative") + }) + + test("does not apply 'relative' class when loadData is true", async () => { + mockUseSWR.mockReturnValue({ + data: mockPathsData, + error: undefined, + isLoading: false, + }) + const { getByTestId } = render() + const inViewToggleButton = getByTestId("in-view-toggle-button") + fireEvent.click(inViewToggleButton) + await waitFor(() => { + const inViewElement = getByTestId("in-view") + expect(inViewElement).not.toHaveClass("relative") + }) + }) +}) + +describe("browser environment", () => { + test("handles non-browser environment", () => { + mockUseIsBrowser.mockReturnValue({ isBrowser: false }) + + const { getByTestId } = render() + const inViewElement = getByTestId("in-view") + expect(inViewElement).toBeInTheDocument() + expect(inViewElement).not.toHaveAttribute("data-root") + }) + + test("uses document.body as root when scrollableElement is window", () => { + mockIsElmWindow.mockReturnValue(true) + mockUseScrollController.mockReturnValue({ + scrollableElement: window as unknown as HTMLElement, + scrollToTop: mockScrollToTop, + }) + mockUseIsBrowser.mockReturnValue({ isBrowser: true }) + + const { getByTestId } = render() + const inViewElement = getByTestId("in-view") + expect(inViewElement).toBeInTheDocument() + expect(inViewElement).toHaveAttribute("data-root", "BODY") + }) + + test("uses scrollableElement as root when it is not window", () => { + const mockScrollableElement = document.createElement("div") + mockIsElmWindow.mockReturnValue(false) + mockUseIsBrowser.mockReturnValue({ isBrowser: true }) + mockUseScrollController.mockReturnValue({ + scrollableElement: mockScrollableElement, + scrollToTop: mockScrollToTop, + }) + + const { getByTestId } = render() + const inViewElement = getByTestId("in-view") + expect(inViewElement).toBeInTheDocument() + expect(inViewElement).toHaveAttribute("data-root", "DIV") + }) +}) + diff --git a/www/apps/api-reference/components/Tags/Section/index.tsx b/www/apps/api-reference/components/Tags/Section/index.tsx index 657acc63c8..1c7c3ae279 100644 --- a/www/apps/api-reference/components/Tags/Section/index.tsx +++ b/www/apps/api-reference/components/Tags/Section/index.tsx @@ -1,5 +1,6 @@ "use client" +import React from "react" import { InView } from "react-intersection-observer" import { useEffect, useMemo, useState } from "react" import { @@ -9,6 +10,8 @@ import { useIsBrowser, useScrollController, useSidebar, + Loading, + Link, } from "docs-ui" import dynamic from "next/dynamic" import type { SectionProps } from "../../Section" @@ -19,7 +22,6 @@ import SectionContainer from "../../Section/Container" import { useArea } from "@/providers/area" import SectionDivider from "../../Section/Divider" import clsx from "clsx" -import { Loading, Link } from "docs-ui" import { useRouter } from "next/navigation" import { OpenAPI } from "types" import TagSectionSchema from "./Schema" @@ -36,11 +38,11 @@ export type TagSectionProps = { } & React.HTMLAttributes const Section = dynamic( - async () => import("../../Section") + async () => import("@/components/Section") ) as React.FC const MDXContentClient = dynamic( - async () => import("../../MDXContent/Client"), + async () => import("@/components/MDXContent/Client"), { loading: () => , } diff --git a/www/apps/api-reference/components/Tags/__tests__/index.test.tsx b/www/apps/api-reference/components/Tags/__tests__/index.test.tsx new file mode 100644 index 0000000000..83b1f36866 --- /dev/null +++ b/www/apps/api-reference/components/Tags/__tests__/index.test.tsx @@ -0,0 +1,49 @@ +import React from "react" +import { beforeEach, describe, expect, test, vi } from "vitest" +import { cleanup, render, waitFor } from "@testing-library/react" +import { OpenAPI } from "types" + +// mock data +const mockTags: OpenAPI.TagObject[] = [ + { + name: "mockTag", + description: "Mock Tag", + }, +] + +// mock components +vi.mock("@/components/Tags/Section", () => ({ + default: ({ tag }: { tag: OpenAPI.TagObject }) => ( +
{tag.name}
+ ), +})) +vi.mock("react", async () => { + const actual = await vi.importActual("react") + + return { + ...actual, + Suspense: ({ children }: { children: React.ReactNode }) => <>{children}, + } +}) + +import Tags from ".." + +beforeEach(() => { + vi.clearAllMocks() + cleanup() +}) + +describe("Tags", () => { + test("does not render tags when tags is undefined", () => { + const { container } = render() + expect(container).toBeEmptyDOMElement() + }) + + test("renders tags when tags is defined", async () => { + const { container } = render() + await waitFor(() => { + const tagSections = container.querySelectorAll("[data-testid='tag-section']") + expect(tagSections).toHaveLength(mockTags.length) + }) + }) +}) \ No newline at end of file diff --git a/www/apps/api-reference/components/Tags/index.tsx b/www/apps/api-reference/components/Tags/index.tsx index a1a2002839..84698eab1d 100644 --- a/www/apps/api-reference/components/Tags/index.tsx +++ b/www/apps/api-reference/components/Tags/index.tsx @@ -1,7 +1,7 @@ +import React, { Suspense } from "react" import { OpenAPI } from "types" import { TagSectionProps } from "./Section" import dynamic from "next/dynamic" -import { Suspense } from "react" const TagSection = dynamic( async () => import("./Section") diff --git a/www/apps/api-reference/eslint.config.mjs b/www/apps/api-reference/eslint.config.mjs index 722b100ce3..ad8117f072 100644 --- a/www/apps/api-reference/eslint.config.mjs +++ b/www/apps/api-reference/eslint.config.mjs @@ -29,6 +29,8 @@ export default [ "**/public", "**/.eslintrc.js", "**/generated", + "**/__tests__", + "**/__mocks__", ], }, ...compat.extends( diff --git a/www/apps/api-reference/package.json b/www/apps/api-reference/package.json index 645f366acf..021d41deeb 100644 --- a/www/apps/api-reference/package.json +++ b/www/apps/api-reference/package.json @@ -11,7 +11,8 @@ "start": "next start", "start:monorepo": "yarn start -p 3000", "lint": "next lint --fix", - "prep": "node ./scripts/prepare.mjs" + "prep": "node ./scripts/prepare.mjs", + "test": "vitest" }, "dependencies": { "@mdx-js/loader": "^3.1.0", @@ -64,7 +65,9 @@ "eslint": "^9.13.0", "eslint-plugin-prettier": "^5.2.1", "eslint-plugin-react-hooks": "^5.0.0", - "types": "*" + "types": "*", + "vite-tsconfig-paths": "^5.1.4", + "vitest": "^2.1.8" }, "engines": { "node": ">=20" diff --git a/www/apps/api-reference/providers/__tests__/area.test.tsx b/www/apps/api-reference/providers/__tests__/area.test.tsx new file mode 100644 index 0000000000..aa3e6e0a5b --- /dev/null +++ b/www/apps/api-reference/providers/__tests__/area.test.tsx @@ -0,0 +1,182 @@ +import React from "react" +import { beforeEach, describe, expect, test, vi } from "vitest" +import { cleanup, fireEvent, render } from "@testing-library/react" +import { usePathname } from "next/navigation" +import AreaProvider, { useArea } from "../area" +import { OpenAPI } from "types" + +// Mock functions +const mockSetActivePath = vi.fn() +const mockUseSidebar = vi.fn(() => ({ + setActivePath: mockSetActivePath, +})) +const mockUsePathname = vi.fn(() => "/store/test") +const mockCapitalize = vi.fn((str: string) => str.charAt(0).toUpperCase() + str.slice(1)) +// Track previous values for usePrevious mock +// usePrevious returns the value from the previous render +let previousValue: unknown = undefined +const mockUsePrevious = vi.fn((value: unknown) => { + const result = previousValue + previousValue = value + return result +}) + +// Test component that uses the hook +const TestComponent = () => { + const { area, prevArea, displayedArea, setArea } = useArea() + return ( +
+
{area}
+
{prevArea || "undefined"}
+
{displayedArea}
+ + +
+ ) +} + +vi.mock("docs-ui", () => ({ + capitalize: (str: string) => mockCapitalize(str), + usePrevious: (value: unknown) => mockUsePrevious(value), + useSidebar: () => mockUseSidebar(), +})) + +vi.mock("next/navigation", () => ({ + usePathname: () => mockUsePathname(), +})) + +describe("AreaProvider", () => { + beforeEach(() => { + vi.clearAllMocks() + cleanup() + previousValue = undefined + mockUsePathname.mockReturnValue("/store/test") + mockCapitalize.mockImplementation((str: string) => str.charAt(0).toUpperCase() + str.slice(1)) + }) + + describe("rendering", () => { + test("renders children", () => { + const { getByText } = render( + +
Test Content
+
+ ) + expect(getByText("Test Content")).toBeInTheDocument() + }) + }) + + describe("initial state", () => { + test("initializes with passed area", () => { + const { getByTestId } = render( + + + + ) + expect(getByTestId("area")).toHaveTextContent("store") + }) + + test("displays capitalized area", () => { + const { getByTestId } = render( + + + + ) + expect(getByTestId("displayed-area")).toHaveTextContent("Store") + expect(mockCapitalize).toHaveBeenCalledWith("store") + }) + + test("prevArea is undefined initially", () => { + const { getByTestId } = render( + + + + ) + expect(getByTestId("prev-area")).toHaveTextContent("undefined") + }) + }) + + describe("setArea", () => { + test("updates area when setArea is called", () => { + const { getByTestId } = render( + + + + ) + const setAdminButton = getByTestId("set-area-admin") + const areaElement = getByTestId("area") + + expect(areaElement).toHaveTextContent("store") + fireEvent.click(setAdminButton) + expect(areaElement).toHaveTextContent("admin") + }) + + test("updates displayedArea when area changes", () => { + const { getByTestId } = render( + + + + ) + const setAdminButton = getByTestId("set-area-admin") + const displayedAreaElement = getByTestId("displayed-area") + + expect(displayedAreaElement).toHaveTextContent("Store") + fireEvent.click(setAdminButton) + expect(displayedAreaElement).toHaveTextContent("Admin") + }) + }) + + describe("useEffect behavior", () => { + test("calls setActivePath(null) when pathname changes", () => { + const { rerender } = render( + + + + ) + + mockUsePathname.mockReturnValue("/admin/test") + rerender( + + + + ) + + expect(mockSetActivePath).toHaveBeenCalledWith(null) + }) + }) + + describe("useArea hook", () => { + test("throws error when used outside provider", () => { + const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {}) + + expect(() => { + render() + }).toThrow("useAreaProvider must be used inside an AreaProvider") + + consoleSpy.mockRestore() + }) + + test("returns area, prevArea, displayedArea, and setArea", () => { + const { getByTestId } = render( + + + + ) + expect(getByTestId("area")).toBeInTheDocument() + expect(getByTestId("prev-area")).toBeInTheDocument() + expect(getByTestId("displayed-area")).toBeInTheDocument() + expect(getByTestId("set-area-store")).toBeInTheDocument() + expect(getByTestId("set-area-admin")).toBeInTheDocument() + }) + }) +}) + diff --git a/www/apps/api-reference/providers/__tests__/base-specs.test.tsx b/www/apps/api-reference/providers/__tests__/base-specs.test.tsx new file mode 100644 index 0000000000..db0a7eb84f --- /dev/null +++ b/www/apps/api-reference/providers/__tests__/base-specs.test.tsx @@ -0,0 +1,363 @@ +import React from "react" +import { beforeEach, describe, expect, test, vi } from "vitest" +import { cleanup, render, waitFor } from "@testing-library/react" +import { OpenAPI, Sidebar } from "types" + +// mock data +const mockShownSidebar: Sidebar.Sidebar = { + sidebar_id: "test-sidebar", + title: "Test Sidebar", + items: [], +} + +const mockBaseSpecs: OpenAPI.ExpandedDocument = { + openapi: "3.0.0", + info: { + title: "Test API", + version: "1.0.0", + }, + tags: [ + { + name: "TestTag", + }, + ], + expandedTags: { + "testtag": { + "get": { + summary: "Test Operation", + description: "Test Operation Description", + parameters: [], + }, + }, + }, + paths: { + "/test-path": { + get: { + operationId: "test-operation", + summary: "Test Operation", + description: "Test Operation Description", + "x-authenticated": false, + "x-codeSamples": [], + parameters: [], + responses: { + "200": { + description: "OK", + content: {} + } + }, + requestBody: { + content: {} + } + }, + }, + }, + components: { + securitySchemes: { + "test-security": { + type: "http", + scheme: "bearer", + }, + }, + }, +} + +// Mock functions +const mockPush = vi.fn() +const mockReplace = vi.fn() +const mockUseRouter = vi.fn(() => ({ + push: mockPush, + replace: mockReplace, +})) +const mockSetActivePath = vi.fn() +const mockResetItems = vi.fn() +const mockUpdateItems = vi.fn() +const mockUseSidebar = vi.fn(() => ({ + activePath: "testtag", + setActivePath: mockSetActivePath, + resetItems: mockResetItems, + updateItems: mockUpdateItems, + shownSidebar: mockShownSidebar as Sidebar.Sidebar | Sidebar.SidebarItemSidebar | undefined, +})) +const mockGetSectionId = vi.fn((parts: string[]) => parts.join("-").toLowerCase()) +const mockGetTagChildSidebarItems = vi.fn(() => [] as Sidebar.SidebarItem[]) + +vi.mock("next/navigation", () => ({ + useRouter: () => mockUseRouter(), +})) + +vi.mock("docs-ui", () => ({ + useSidebar: () => mockUseSidebar(), +})) + +vi.mock("docs-utils", () => ({ + getSectionId: (parts: string[]) => mockGetSectionId(parts), +})) + +vi.mock("@/utils/get-tag-child-sidebar-items", () => ({ + default: () => mockGetTagChildSidebarItems(), +})) + +import BaseSpecsProvider, { useBaseSpecs } from "../base-specs" + +// Test component that uses the hook +const TestComponent = () => { + const { baseSpecs, getSecuritySchema } = useBaseSpecs() + return ( +
+
{baseSpecs ? "present" : "null"}
+
+ {getSecuritySchema("test-security") ? "found" : "null"} +
+
+ ) +} + +describe("BaseSpecsProvider", () => { + beforeEach(() => { + vi.clearAllMocks() + cleanup() + window.location.hash = "" + mockGetSectionId.mockImplementation((parts: string[]) => parts.join("-").toLowerCase()) + mockGetTagChildSidebarItems.mockReturnValue([]) + }) + + describe("rendering", () => { + test("renders children", () => { + const { getByText } = render( + +
Test Content
+
+ ) + expect(getByText("Test Content")).toBeInTheDocument() + }) + }) + + describe("useBaseSpecs hook", () => { + test("throws error when used outside provider", () => { + const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {}) + + expect(() => { + render() + }).toThrow("useBaseSpecs must be used inside a BaseSpecsProvider") + + consoleSpy.mockRestore() + }) + + test("returns baseSpecs and getSecuritySchema", () => { + const { getByTestId } = render( + + + + ) + expect(getByTestId("base-specs")).toHaveTextContent("present") + expect(getByTestId("security-schema")).toBeInTheDocument() + }) + }) + + describe("getSecuritySchema", () => { + test("returns security schema when it exists", () => { + const { getByTestId } = render( + + + + ) + const securitySchema = getByTestId("security-schema") + expect(securitySchema).toHaveTextContent("found") + }) + + test("returns null when security schema does not exist", () => { + const { getByTestId } = render( + + + + ) + const securitySchema = getByTestId("security-schema") + // Change to a non-existent security name + const TestComponent2 = () => { + const { getSecuritySchema } = useBaseSpecs() + return ( +
+ {getSecuritySchema("non-existent") ? "found" : "null"} +
+ ) + } + const { getByTestId: getByTestId2 } = render( + + + + ) + expect(getByTestId2("security-schema-2")).toHaveTextContent("null") + }) + + test("returns null when security schema is a ref", () => { + const baseSpecsWithRef: OpenAPI.ExpandedDocument = { + ...mockBaseSpecs, + components: { + securitySchemes: { + "test-security": { + $ref: "#/components/securitySchemes/OtherSecurity", + }, + }, + }, + } as OpenAPI.ExpandedDocument + + const TestComponent3 = () => { + const { getSecuritySchema } = useBaseSpecs() + return ( +
+ {getSecuritySchema("test-security") ? "found" : "null"} +
+ ) + } + + const { getByTestId } = render( + + + + ) + expect(getByTestId("security-schema-3")).toHaveTextContent("null") + }) + }) + + describe("itemsToUpdate", () => { + test("generates itemsToUpdate from baseSpecs tags", async () => { + const mockSidebar: Sidebar.Sidebar = { + sidebar_id: "test-sidebar", + title: "Test Sidebar", + items: [], + } + mockUseSidebar.mockReturnValue({ + activePath: "testtag", + setActivePath: mockSetActivePath, + resetItems: mockResetItems, + updateItems: mockUpdateItems, + shownSidebar: mockSidebar, + }) + mockGetTagChildSidebarItems.mockReturnValue([ + { + type: "link", + path: "/test-path", + title: "Test Link", + }, + ]) + + render( + +
Test
+
+ ) + + await waitFor(() => { + expect(mockUpdateItems).toHaveBeenCalled() + }) + + const updateCall = mockUpdateItems.mock.calls[0][0] + expect(updateCall.sidebar_id).toBe("test-sidebar") + expect(updateCall.items).toBeDefined() + expect(Array.isArray(updateCall.items)).toBe(true) + expect(updateCall.items.length).toBeGreaterThan(0) + }) + + test("does not update items when baseSpecs is undefined", () => { + render( + +
Test
+
+ ) + + expect(mockUpdateItems).not.toHaveBeenCalled() + }) + + test("does not update items when shownSidebar is null", () => { + mockUseSidebar.mockReturnValue({ + activePath: "testtag", + setActivePath: mockSetActivePath, + resetItems: mockResetItems, + updateItems: mockUpdateItems, + shownSidebar: undefined, + }) + render( + +
Test
+
+ ) + + expect(mockUpdateItems).not.toHaveBeenCalled() + }) + + test("includes onOpen handler that updates hash and activePath", async () => { + mockUseSidebar.mockReturnValue({ + activePath: "something-else", + setActivePath: mockSetActivePath, + resetItems: mockResetItems, + updateItems: mockUpdateItems, + shownSidebar: mockShownSidebar, + }) + window.location.hash = "" + + render( + +
Test
+
+ ) + + await waitFor(() => { + expect(mockUpdateItems).toHaveBeenCalled() + }) + + const updateCall = mockUpdateItems.mock.calls[0][0] + const item = updateCall.items[0] + expect(item.newItem.onOpen).toBeDefined() + + // Call onOpen + item.newItem.onOpen() + expect(mockPush).toHaveBeenCalledWith("#testtag", { scroll: false }) + expect(mockSetActivePath).toHaveBeenCalledWith("testtag") + }) + + test("onOpen does not update hash when it already matches", async () => { + mockUseSidebar.mockReturnValue({ + activePath: "testtag", + setActivePath: mockSetActivePath, + resetItems: mockResetItems, + updateItems: mockUpdateItems, + shownSidebar: mockShownSidebar, + }) + window.location.hash = "#testtag" + + render( + +
Test
+
+ ) + + await waitFor(() => { + expect(mockUpdateItems).toHaveBeenCalled() + }) + + const updateCall = mockUpdateItems.mock.calls[0][0] + const item = updateCall.items[0] + mockPush.mockClear() + mockSetActivePath.mockClear() + + // Call onOpen + item.newItem.onOpen() + expect(mockPush).not.toHaveBeenCalled() + expect(mockSetActivePath).not.toHaveBeenCalled() + }) + }) + + describe("cleanup", () => { + test("calls resetItems on unmount", () => { + const { unmount } = render( + +
Test
+
+ ) + + unmount() + expect(mockResetItems).toHaveBeenCalled() + }) + }) +}) + diff --git a/www/apps/api-reference/providers/__tests__/loading.test.tsx b/www/apps/api-reference/providers/__tests__/loading.test.tsx new file mode 100644 index 0000000000..bbb4e302d3 --- /dev/null +++ b/www/apps/api-reference/providers/__tests__/loading.test.tsx @@ -0,0 +1,95 @@ +import React from "react" +import { beforeEach, describe, expect, test, vi } from "vitest" +import { cleanup, fireEvent, render } from "@testing-library/react" +import LoadingProvider, { useLoading } from "../loading" + +// Test component that uses the hook +const TestComponent = () => { + const { loading, removeLoading } = useLoading() + return ( +
+
{loading.toString()}
+ +
+ ) +} + +describe("LoadingProvider", () => { + beforeEach(() => { + vi.clearAllMocks() + cleanup() + }) + + describe("rendering", () => { + test("renders children", () => { + const { getByText } = render( + +
Test Content
+
+ ) + expect(getByText("Test Content")).toBeInTheDocument() + }) + }) + + describe("initial loading state", () => { + test("initializes with loading false by default", () => { + const { getByTestId } = render( + + + + ) + expect(getByTestId("loading-state")).toHaveTextContent("false") + }) + + test("initializes with loading true when initialLoading is true", () => { + const { getByTestId } = render( + + + + ) + expect(getByTestId("loading-state")).toHaveTextContent("true") + }) + }) + + describe("removeLoading", () => { + test("sets loading to false when removeLoading is called", () => { + const { getByTestId } = render( + + + + ) + const removeLoadingButton = getByTestId("remove-loading") + const loadingState = getByTestId("loading-state") + + expect(loadingState).toHaveTextContent("true") + fireEvent.click(removeLoadingButton) + expect(loadingState).toHaveTextContent("false") + }) + }) + + describe("useLoading hook", () => { + test("throws error when used outside provider", () => { + // Suppress console.error for this test + const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {}) + + expect(() => { + render() + }).toThrow("useLoading must be used inside a LoadingProvider") + + consoleSpy.mockRestore() + }) + + test("returns loading state and removeLoading function", () => { + const { getByTestId } = render( + + + + ) + expect(getByTestId("loading-state")).toBeInTheDocument() + expect(getByTestId("remove-loading")).toBeInTheDocument() + }) + }) +}) + diff --git a/www/apps/api-reference/providers/__tests__/main-nav.test.tsx b/www/apps/api-reference/providers/__tests__/main-nav.test.tsx new file mode 100644 index 0000000000..71ef4da7f9 --- /dev/null +++ b/www/apps/api-reference/providers/__tests__/main-nav.test.tsx @@ -0,0 +1,80 @@ +import React from "react" +import { beforeEach, describe, expect, test, vi } from "vitest" +import { cleanup, render } from "@testing-library/react" +import { MainNavProvider } from "../main-nav" + +// Mock functions +const mockGetNavDropdownItems = vi.fn((options: unknown) => [ + { + title: "Test Item", + path: "/test", + }, +]) + +vi.mock("docs-ui", () => ({ + getNavDropdownItems: (options: unknown) => mockGetNavDropdownItems(options), + MainNavProvider: ({ children, navItems }: { children: React.ReactNode; navItems: unknown[] }) => ( +
+ {children} +
+ ), +})) + +vi.mock("@/config", () => ({ + config: { + baseUrl: "https://test.com", + }, +})) + +describe("MainNavProvider", () => { + beforeEach(() => { + vi.clearAllMocks() + cleanup() + }) + + describe("rendering", () => { + test("renders children", () => { + const { getByText } = render( + +
Test Content
+
+ ) + expect(getByText("Test Content")).toBeInTheDocument() + }) + + test("renders UiMainNavProvider with navItems", () => { + const { getByTestId } = render( + +
Test
+
+ ) + const uiProvider = getByTestId("ui-main-nav-provider") + expect(uiProvider).toBeInTheDocument() + expect(mockGetNavDropdownItems).toHaveBeenCalledWith({ + basePath: "https://test.com", + }) + }) + }) + + describe("navigationDropdownItems", () => { + test("memoizes navigationDropdownItems", () => { + const { rerender } = render( + +
Test
+
+ ) + + const callCount = mockGetNavDropdownItems.mock.calls.length + + rerender( + +
Test
+
+ ) + + // Should not call getNavDropdownItems again due to memoization + expect(mockGetNavDropdownItems.mock.calls.length).toBe(callCount) + }) + }) +}) + diff --git a/www/apps/api-reference/providers/__tests__/page-title.test.tsx b/www/apps/api-reference/providers/__tests__/page-title.test.tsx new file mode 100644 index 0000000000..24afe513c7 --- /dev/null +++ b/www/apps/api-reference/providers/__tests__/page-title.test.tsx @@ -0,0 +1,206 @@ +import React from "react" +import { beforeEach, describe, expect, test, vi } from "vitest" +import { cleanup, render } from "@testing-library/react" +import PageTitleProvider from "../page-title" +import { Sidebar } from "types" + +// Mock functions +const mockUseSidebar = vi.fn(() => ({ + activePath: "test-path" as string | null, + activeItem: { + type: "link", + path: "test-path", + title: "Test Item", + }, +})) +const mockUseArea = vi.fn(() => ({ + displayedArea: "Store", +})) + +vi.mock("docs-ui", () => ({ + useSidebar: () => mockUseSidebar(), +})) + +vi.mock("@/providers/area", () => ({ + useArea: () => mockUseArea(), +})) + +describe("PageTitleProvider", () => { + beforeEach(() => { + vi.clearAllMocks() + cleanup() + document.title = "" + mockUseSidebar.mockReturnValue({ + activePath: "test-path", + activeItem: { + type: "link", + path: "test-path", + title: "Test Item", + }, + }) + mockUseArea.mockReturnValue({ + displayedArea: "Store", + }) + }) + + describe("rendering", () => { + test("renders children", () => { + const { getByText } = render( + +
Test Content
+
+ ) + expect(getByText("Test Content")).toBeInTheDocument() + }) + }) + + describe("document title", () => { + test("sets title to suffix when activePath is null", () => { + mockUseSidebar.mockReturnValue({ + activePath: null, + activeItem: { + type: "link", + path: "test-path", + title: "Test Item", + }, + }) + render( + +
Test
+
+ ) + expect(document.title).toBe("Medusa Store API Reference") + }) + + test("sets title to suffix when activePath is empty string", () => { + mockUseSidebar.mockReturnValue({ + activePath: "", + activeItem: { + type: "link", + path: "test-path", + title: "Test Item", + }, + }) + render( + +
Test
+
+ ) + expect(document.title).toBe("Medusa Store API Reference") + }) + + test("sets title with activeItem title when activeItem.path matches activePath", () => { + const mockItem: Sidebar.SidebarItemLink = { + type: "link", + path: "/test-path", + title: "Test Item", + } + mockUseSidebar.mockReturnValue({ + activePath: "/test-path", + activeItem: mockItem, + }) + + render( + +
Test
+
+ ) + + expect(document.title).toBe("Test Item - Medusa Store API Reference") + }) + + test("sets title with child item title when activeItem has matching child", () => { + const mockChildItem: Sidebar.SidebarItemLink = { + type: "link", + path: "/child-path", + title: "Child Item", + } + const mockItem: Sidebar.SidebarItemLink = { + type: "link", + path: "/parent-path", + title: "Parent Item", + children: [mockChildItem], + } + mockUseSidebar.mockReturnValue({ + activePath: "/child-path", + activeItem: mockItem, + }) + + render( + +
Test
+
+ ) + + expect(document.title).toBe("Child Item - Medusa Store API Reference") + }) + + test("sets title to suffix when activeItem has no matching child", () => { + const mockItem: Sidebar.SidebarItemLink = { + type: "link", + path: "/parent-path", + title: "Parent Item", + children: [ + { + type: "link", + path: "/other-path", + title: "Other Item", + }, + ], + } + mockUseSidebar.mockReturnValue({ + activePath: "/child-path", + activeItem: mockItem, + }) + + render( + +
Test
+
+ ) + + expect(document.title).toBe("Medusa Store API Reference") + }) + + test("updates title when displayedArea changes", () => { + mockUseArea.mockReturnValue({ + displayedArea: "Admin", + }) + render( + +
Test
+
+ ) + expect(document.title).toBe("Test Item - Medusa Admin API Reference") + }) + + test("updates title when activePath changes", () => { + const { rerender } = render( + +
Test
+
+ ) + + expect(document.title).toBe("Test Item - Medusa Store API Reference") + + const mockItem: Sidebar.SidebarItemLink = { + type: "link", + path: "/new-path", + title: "New Item", + } + mockUseSidebar.mockReturnValue({ + activePath: "/new-path", + activeItem: mockItem, + }) + + rerender( + +
Test
+
+ ) + + expect(document.title).toBe("New Item - Medusa Store API Reference") + }) + }) +}) + diff --git a/www/apps/api-reference/providers/__tests__/search.test.tsx b/www/apps/api-reference/providers/__tests__/search.test.tsx new file mode 100644 index 0000000000..7b287132f5 --- /dev/null +++ b/www/apps/api-reference/providers/__tests__/search.test.tsx @@ -0,0 +1,162 @@ +import React from "react" +import { beforeEach, describe, expect, test, vi } from "vitest" +import { cleanup, render } from "@testing-library/react" +import SearchProvider from "../search" + +// Mock functions +const mockIsLoading = vi.fn(() => false) +const mockUsePageLoading = vi.fn(() => ({ + isLoading: mockIsLoading(), +})) +const mockBasePathUrl = vi.fn((url: string) => url) + +vi.mock("docs-ui", () => ({ + usePageLoading: () => mockUsePageLoading(), + SearchProvider: ({ + children, + algolia, + indices, + defaultIndex, + searchProps, + }: { + children: React.ReactNode + algolia: unknown + indices: unknown[] + defaultIndex: string + searchProps: unknown + }) => ( +
+ {children} +
+ ), +})) + +vi.mock("@/config", () => ({ + config: { + baseUrl: "https://test.com", + }, +})) + +vi.mock("@/utils/base-path-url", () => ({ + default: (url: string) => mockBasePathUrl(url), +})) + +describe("SearchProvider", () => { + beforeEach(() => { + vi.clearAllMocks() + cleanup() + // Set environment variables + process.env.NEXT_PUBLIC_ALGOLIA_APP_ID = "test-app-id" + process.env.NEXT_PUBLIC_ALGOLIA_API_KEY = "test-api-key" + process.env.NEXT_PUBLIC_API_ALGOLIA_INDEX_NAME = "test-api-index" + process.env.NEXT_PUBLIC_DOCS_ALGOLIA_INDEX_NAME = "test-docs-index" + mockIsLoading.mockReturnValue(false) + }) + + describe("rendering", () => { + test("renders children", () => { + const { getByText } = render( + +
Test Content
+
+ ) + expect(getByText("Test Content")).toBeInTheDocument() + }) + + test("renders UiSearchProvider with correct props", () => { + const { getByTestId } = render( + +
Test
+
+ ) + const uiProvider = getByTestId("ui-search-provider") + expect(uiProvider).toBeInTheDocument() + + const algolia = JSON.parse(uiProvider.getAttribute("data-algolia") || "{}") + expect(algolia).toEqual({ + appId: "test-app-id", + apiKey: "test-api-key", + mainIndexName: "test-api-index", + }) + + const indices = JSON.parse(uiProvider.getAttribute("data-indices") || "[]") + expect(indices).toEqual([ + { + value: "test-docs-index", + title: "Docs", + }, + { + value: "test-api-index", + title: "Store & Admin API", + }, + ]) + + expect(uiProvider.getAttribute("data-default-index")).toBe("test-api-index") + }) + }) + + describe("environment variables", () => { + test("uses default values when env vars are not set", () => { + delete process.env.NEXT_PUBLIC_ALGOLIA_APP_ID + delete process.env.NEXT_PUBLIC_ALGOLIA_API_KEY + delete process.env.NEXT_PUBLIC_API_ALGOLIA_INDEX_NAME + delete process.env.NEXT_PUBLIC_DOCS_ALGOLIA_INDEX_NAME + + const { getByTestId } = render( + +
Test
+
+ ) + const uiProvider = getByTestId("ui-search-provider") + const algolia = JSON.parse(uiProvider.getAttribute("data-algolia") || "{}") + expect(algolia.appId).toBe("temp") + expect(algolia.apiKey).toBe("temp") + expect(algolia.mainIndexName).toBe("temp") + }) + }) + + describe("searchProps", () => { + test("passes isLoading from usePageLoading", () => { + mockIsLoading.mockReturnValue(true) + const { getByTestId } = render( + +
Test
+
+ ) + const uiProvider = getByTestId("ui-search-provider") + const searchProps = JSON.parse(uiProvider.getAttribute("data-search-props") || "{}") + expect(searchProps.isLoading).toBe(true) + }) + + test("includes suggestions", () => { + const { getByTestId } = render( + +
Test
+
+ ) + const uiProvider = getByTestId("ui-search-provider") + const searchProps = JSON.parse(uiProvider.getAttribute("data-search-props") || "{}") + expect(searchProps.suggestions).toBeDefined() + expect(Array.isArray(searchProps.suggestions)).toBe(true) + expect(searchProps.suggestions.length).toBeGreaterThan(0) + }) + + test("includes checkInternalPattern regex", () => { + const { getByTestId } = render( + +
Test
+
+ ) + const uiProvider = getByTestId("ui-search-provider") + const searchProps = JSON.parse(uiProvider.getAttribute("data-search-props") || "{}") + expect(searchProps.checkInternalPattern).toBeDefined() + }) + }) +}) + diff --git a/www/apps/api-reference/providers/__tests__/sidebar.test.tsx b/www/apps/api-reference/providers/__tests__/sidebar.test.tsx new file mode 100644 index 0000000000..5a02630d0c --- /dev/null +++ b/www/apps/api-reference/providers/__tests__/sidebar.test.tsx @@ -0,0 +1,237 @@ +import React from "react" +import { beforeEach, describe, expect, test, vi } from "vitest" +import { cleanup, render, waitFor } from "@testing-library/react" +import SidebarProvider from "../sidebar" +import { Sidebar } from "types" + +// Mock functions +const mockIsLoading = vi.fn(() => false) +const mockSetIsLoading = vi.fn() +const mockUsePageLoading = vi.fn(() => ({ + isLoading: mockIsLoading(), + setIsLoading: mockSetIsLoading, +})) +const mockScrollableElement = vi.fn(() => null as HTMLElement | null) +const mockUseScrollController = vi.fn(() => ({ + scrollableElement: mockScrollableElement(), +})) +const mockPathname = vi.fn(() => "/store/test") +const mockUsePathname = vi.fn(() => mockPathname()) + +const mockStoreSidebar: Sidebar.Sidebar = { + sidebar_id: "store-sidebar", + title: "Store Sidebar", + items: [], +} + +const mockAdminSidebar: Sidebar.Sidebar = { + sidebar_id: "admin-sidebar", + title: "Admin Sidebar", + items: [], +} + +vi.mock("docs-ui", () => ({ + SidebarProvider: ({ + children, + isLoading, + setIsLoading, + shouldHandleHashChange, + shouldHandlePathChange, + scrollableElement, + sidebars, + persistCategoryState, + disableActiveTransition, + isSidebarStatic, + }: { + children: React.ReactNode + isLoading: boolean + setIsLoading: (value: boolean) => void + shouldHandleHashChange: boolean + shouldHandlePathChange: boolean + scrollableElement: HTMLElement | null + sidebars: Sidebar.Sidebar[] + persistCategoryState: boolean + disableActiveTransition: boolean + isSidebarStatic: boolean + }) => ( +
+ {children} +
+ ), + usePageLoading: () => mockUsePageLoading(), + useScrollController: () => mockUseScrollController(), +})) + +vi.mock("next/navigation", () => ({ + usePathname: () => mockUsePathname(), +})) + +vi.mock("@/config", () => ({ + config: { + sidebars: [{ + sidebar_id: "store-sidebar", + title: "Store Sidebar", + items: [], + }], + }, +})) + +// Mock dynamic imports +vi.mock("@/generated/generated-store-sidebar.mjs", () => ({ + default: mockStoreSidebar, +})) + +vi.mock("@/generated/generated-admin-sidebar.mjs", () => ({ + default: mockAdminSidebar, +})) + +describe("SidebarProvider", () => { + beforeEach(() => { + vi.clearAllMocks() + cleanup() + mockIsLoading.mockReturnValue(false) + mockScrollableElement.mockReturnValue(null) + mockPathname.mockReturnValue("/store/test") + }) + + describe("rendering", () => { + test("renders children", () => { + const { getByText } = render( + +
Test Content
+
+ ) + expect(getByText("Test Content")).toBeInTheDocument() + }) + + test("renders UiSidebarProvider with correct props", async () => { + const { getByTestId } = render( + +
Test
+
+ ) + + await waitFor(() => { + const uiProvider = getByTestId("ui-sidebar-provider") + expect(uiProvider).toBeInTheDocument() + expect(uiProvider.getAttribute("data-is-loading")).toBe("false") + expect(uiProvider.getAttribute("data-should-handle-hash-change")).toBe("true") + expect(uiProvider.getAttribute("data-should-handle-path-change")).toBe("false") + expect(uiProvider.getAttribute("data-persist-category-state")).toBe("false") + expect(uiProvider.getAttribute("data-disable-active-transition")).toBe("false") + expect(uiProvider.getAttribute("data-is-sidebar-static")).toBe("false") + }) + }) + }) + + describe("sidebar loading", () => { + test("loads store sidebar when path starts with /store", async () => { + mockPathname.mockReturnValue("/store/test") + const { getByTestId } = render( + +
Test
+
+ ) + + await waitFor(() => { + const uiProvider = getByTestId("ui-sidebar-provider") + const sidebars = JSON.parse(uiProvider.getAttribute("data-sidebars") || "[]") + expect(sidebars).toEqual([mockStoreSidebar]) + }) + }) + + test("loads admin sidebar when path does not start with /store", async () => { + mockPathname.mockReturnValue("/admin/test") + const { getByTestId } = render( + +
Test
+
+ ) + + await waitFor(() => { + const uiProvider = getByTestId("ui-sidebar-provider") + const sidebars = JSON.parse(uiProvider.getAttribute("data-sidebars") || "[]") + expect(sidebars).toEqual([mockAdminSidebar]) + }) + }) + + test("uses config sidebars as fallback when sidebar is not loaded", () => { + const { getByTestId } = render( + +
Test
+
+ ) + + // Initially should use config sidebars + const uiProvider = getByTestId("ui-sidebar-provider") + const sidebars = JSON.parse(uiProvider.getAttribute("data-sidebars") || "[]") + expect(sidebars).toEqual([mockStoreSidebar]) + }) + }) + + describe("props passing", () => { + test("passes isLoading and setIsLoading to UiSidebarProvider", async () => { + mockIsLoading.mockReturnValue(true) + const { getByTestId } = render( + +
Test
+
+ ) + + await waitFor(() => { + const uiProvider = getByTestId("ui-sidebar-provider") + expect(uiProvider.getAttribute("data-is-loading")).toBe("true") + }) + }) + + test("passes scrollableElement to UiSidebarProvider", async () => { + const mockElement = document.createElement("div") + mockScrollableElement.mockReturnValue(mockElement) + const { getByTestId } = render( + +
Test
+
+ ) + + await waitFor(() => { + const uiProvider = getByTestId("ui-sidebar-provider") + expect(uiProvider.getAttribute("data-scrollable-element")).toBe("present") + }) + }) + }) + + describe("error handling", () => { + test("handles sidebar loading errors gracefully", async () => { + const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {}) + + // Mock a failing import + vi.doMock("../generated/generated-store-sidebar.mjs", () => { + throw new Error("Failed to load sidebar") + }) + + const { getByTestId } = render( + +
Test
+
+ ) + + await waitFor(() => { + const uiProvider = getByTestId("ui-sidebar-provider") + expect(uiProvider).toBeInTheDocument() + }) + + consoleSpy.mockRestore() + }) + }) +}) + diff --git a/www/apps/api-reference/providers/area.tsx b/www/apps/api-reference/providers/area.tsx index 3ad9b5f624..c63415bc8f 100644 --- a/www/apps/api-reference/providers/area.tsx +++ b/www/apps/api-reference/providers/area.tsx @@ -1,5 +1,6 @@ "use client" +import React from "react" import type { OpenAPI } from "types" import { capitalize, usePrevious, useSidebar } from "docs-ui" import { createContext, useContext, useEffect, useMemo, useState } from "react" diff --git a/www/apps/api-reference/providers/base-specs.tsx b/www/apps/api-reference/providers/base-specs.tsx index 65ba96e887..189f0b6b80 100644 --- a/www/apps/api-reference/providers/base-specs.tsx +++ b/www/apps/api-reference/providers/base-specs.tsx @@ -1,5 +1,6 @@ "use client" +import React from "react" import { OpenAPI } from "types" import { ReactNode, createContext, useContext, useEffect, useMemo } from "react" import getTagChildSidebarItems from "../utils/get-tag-child-sidebar-items" @@ -68,7 +69,8 @@ const BaseSpecsProvider = ({ children, baseSpecs }: BaseSpecsProviderProps) => { children: childItems, loaded: childItems.length > 0, onOpen: () => { - if (location.hash !== tagPathName) { + const currentHash = location.hash.replace("#", "") + if (currentHash !== tagPathName) { router.push(`#${tagPathName}`, { scroll: false, }) diff --git a/www/apps/api-reference/providers/loading.tsx b/www/apps/api-reference/providers/loading.tsx index 15ff51313c..1882614216 100644 --- a/www/apps/api-reference/providers/loading.tsx +++ b/www/apps/api-reference/providers/loading.tsx @@ -1,3 +1,4 @@ +import React from "react" import { createContext, useContext, useState } from "react" type LoadingContextType = { diff --git a/www/apps/api-reference/providers/main-nav.tsx b/www/apps/api-reference/providers/main-nav.tsx index cdb5a18040..43bd1619fb 100644 --- a/www/apps/api-reference/providers/main-nav.tsx +++ b/www/apps/api-reference/providers/main-nav.tsx @@ -1,5 +1,6 @@ "use client" +import React from "react" import { getNavDropdownItems, MainNavProvider as UiMainNavProvider, diff --git a/www/apps/api-reference/providers/page-title.tsx b/www/apps/api-reference/providers/page-title.tsx index 5b40e0200a..9b0ef69b3f 100644 --- a/www/apps/api-reference/providers/page-title.tsx +++ b/www/apps/api-reference/providers/page-title.tsx @@ -1,5 +1,6 @@ "use client" +import React from "react" import { createContext, useEffect } from "react" import { useSidebar } from "docs-ui" import { useArea } from "./area" @@ -30,6 +31,8 @@ const PageTitleProvider = ({ children }: PageTitleProviderProps) => { ) as Sidebar.SidebarItemLink if (item) { document.title = `${item.title} - ${titleSuffix}` + } else { + document.title = titleSuffix } } } diff --git a/www/apps/api-reference/providers/search.tsx b/www/apps/api-reference/providers/search.tsx index a642be45a5..985ae7347d 100644 --- a/www/apps/api-reference/providers/search.tsx +++ b/www/apps/api-reference/providers/search.tsx @@ -1,5 +1,6 @@ "use client" +import React from "react" import { usePageLoading, SearchProvider as UiSearchProvider } from "docs-ui" import { config } from "../config" import basePathUrl from "../utils/base-path-url" diff --git a/www/apps/api-reference/providers/sidebar.tsx b/www/apps/api-reference/providers/sidebar.tsx index 42319d26fb..de6775a369 100644 --- a/www/apps/api-reference/providers/sidebar.tsx +++ b/www/apps/api-reference/providers/sidebar.tsx @@ -1,5 +1,6 @@ "use client" +import React from "react" import { SidebarProvider as UiSidebarProvider, usePageLoading, diff --git a/www/apps/api-reference/tsconfig.json b/www/apps/api-reference/tsconfig.json index 48bd244a7f..46ad9c0284 100644 --- a/www/apps/api-reference/tsconfig.json +++ b/www/apps/api-reference/tsconfig.json @@ -22,6 +22,8 @@ "**/*.mjs" ], "exclude": [ - "specs" + "specs", + "**/__tests__", + "**/__mocks__" ] } diff --git a/www/apps/api-reference/tsconfig.tests.json b/www/apps/api-reference/tsconfig.tests.json new file mode 100644 index 0000000000..5baf29e6f9 --- /dev/null +++ b/www/apps/api-reference/tsconfig.tests.json @@ -0,0 +1,16 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "./tsconfig.json", + "include": [ + "**/*.tsx", + "**/*.ts", + "**/*.js", + "src/**/*", + "**/*.mjs", + "__tests__/**/*", + "__mocks__/**/*" + ], + "exclude": [ + "specs" + ] +} \ No newline at end of file diff --git a/www/apps/api-reference/vitest.config.mts b/www/apps/api-reference/vitest.config.mts new file mode 100644 index 0000000000..8c8c5f61a3 --- /dev/null +++ b/www/apps/api-reference/vitest.config.mts @@ -0,0 +1,18 @@ +import { defineConfig } from 'vitest/config' +import react from '@vitejs/plugin-react' +import tsconfigPaths from 'vite-tsconfig-paths' +import { resolve } from 'path' + +export default defineConfig({ + plugins: [ + tsconfigPaths({ + configNames: ["tsconfig.tests.json"] + }), + react() + ], + test: { + environment: 'jsdom', + setupFiles: [resolve(__dirname, '../../vitest.setup.ts')], + }, +}) + diff --git a/www/packages/docs-ui/src/providers/AiAssistant/__tests__/index.test.tsx b/www/packages/docs-ui/src/providers/AiAssistant/__tests__/index.test.tsx index 3acea61de2..1cb94ad937 100644 --- a/www/packages/docs-ui/src/providers/AiAssistant/__tests__/index.test.tsx +++ b/www/packages/docs-ui/src/providers/AiAssistant/__tests__/index.test.tsx @@ -1,7 +1,7 @@ import React from "react" import { afterEach, beforeEach, describe, expect, test, vi } from "vitest" import { cleanup, fireEvent, render, waitFor } from "@testing-library/react" -import * as AiAssistantMocks from "@/components/AiAssistant/__mocks__" +import * as AiAssistantMocks from "../../../components/AiAssistant/__mocks__" // Mock dependencies const mockUseIsBrowser = vi.fn(() => ({ @@ -297,6 +297,9 @@ describe("useAiAssistant hook", () => { // Mock grecaptcha to be available after a delay setTimeout(() => { + if (!window) { + return + } ;(window as unknown as { grecaptcha?: unknown }).grecaptcha = {} }, 100) diff --git a/www/turbo.json b/www/turbo.json index 7b2ee9a1a8..a01871240a 100644 --- a/www/turbo.json +++ b/www/turbo.json @@ -28,7 +28,9 @@ }, "lint": { }, "lint:content": { }, - "test": { }, + "test": { + "dependsOn": ["^build"] + }, "watch": { }, "dev:monorepo": { "dependsOn": [ diff --git a/www/yarn.lock b/www/yarn.lock index 1b10693c64..fe09a9062b 100644 --- a/www/yarn.lock +++ b/www/yarn.lock @@ -6364,6 +6364,8 @@ __metadata: tailwindcss: 3.3.3 types: "*" typescript: 5.1.6 + vite-tsconfig-paths: ^5.1.4 + vitest: ^2.1.8 yaml: ^2.3.1 languageName: unknown linkType: soft