From f7f3d6e0da4644e0ee7962a861926eb39f782c9b Mon Sep 17 00:00:00 2001 From: gpalmer27 Date: Wed, 3 Jun 2026 22:17:15 -0400 Subject: [PATCH 1/6] tests --- .../reviews/unused/collapsable-info.tsx | 49 -- .../_components/reviews/unused/info-card.tsx | 20 - .../reviews/unused/new-role-card.tsx | 38 -- .../reviews/unused/review-search-bar.tsx | 28 - .../_components/search/review-search-bar.tsx | 144 ----- apps/web/test/api-routes.test.ts | 55 ++ apps/web/test/companyStatistics.test.ts | 126 +++++ .../components/admin-access-toast.test.tsx | 75 +++ .../web/test/components/admin-layout.test.tsx | 87 +++ apps/web/test/components/admin-pages.test.tsx | 30 + .../components/all-company-roles.test.tsx | 93 +++ apps/web/test/components/auth.test.tsx | 111 ++++ apps/web/test/components/back-button.test.tsx | 19 + apps/web/test/components/bar-graph.test.tsx | 23 + .../components/basic-info-section.test.tsx | 178 ++++++ apps/web/test/components/combo-box.test.tsx | 60 ++ .../test/components/company-about.test.tsx | 26 + .../components/company-card-preview.test.tsx | 97 ++++ .../company-details-section.test.tsx | 169 ++++++ .../web/test/components/company-info.test.tsx | 125 ++++ .../test/components/company-reviews.test.tsx | 99 ++++ .../components/company-statistics.test.tsx | 105 ++++ .../test/components/compare-context.test.tsx | 236 ++++++++ apps/web/test/components/compare-ui.test.tsx | 350 ++++++++++++ .../test/components/create-user-form.test.tsx | 95 ++++ .../test/components/dashboard-table.test.tsx | 351 ++++++++++++ .../delete-review-dialogue.test.tsx | 63 +++ .../components/draft-review-card.test.tsx | 69 +++ .../test/components/dropdown-filter.test.tsx | 312 ++++++++++ .../components/dropdown-filters-bar.test.tsx | 241 ++++++++ .../existing-company-content.test.tsx | 304 ++++++++++ .../test/components/favorite-button.test.tsx | 70 +++ .../favorite-company-search.test.tsx | 54 ++ .../components/favorite-role-search.test.tsx | 70 +++ apps/web/test/components/filter-body.test.tsx | 423 ++++++++++++++ .../test/components/header-layout.test.tsx | 55 ++ apps/web/test/components/info-icon.test.tsx | 35 ++ .../test/components/interview-modal.test.tsx | 74 +++ .../components/interview-round-item.test.tsx | 116 ++++ .../components/interview-section.test.tsx | 51 ++ .../web/test/components/landing-page.test.tsx | 41 ++ apps/web/test/components/layout.test.tsx | 37 ++ .../test/components/loading-results.test.tsx | 16 + apps/web/test/components/logos.test.tsx | 30 + .../components/mobile-header-button.test.tsx | 61 ++ .../test/components/modal-container.test.tsx | 33 ++ .../components/new-role-dialogue.test.tsx | 57 ++ apps/web/test/components/no-results.test.tsx | 29 + apps/web/test/components/not-found.test.tsx | 22 + .../test/components/on-the-job-modal.test.tsx | 81 +++ .../components/onboarding-dialog.test.tsx | 81 +++ .../test/components/onboarding-form.test.tsx | 147 +++++ .../components/onboarding-wrapper.test.tsx | 55 ++ apps/web/test/components/pay-modal.test.tsx | 71 +++ apps/web/test/components/pip-bar.test.tsx | 47 ++ apps/web/test/components/pip-card.test.tsx | 63 +++ apps/web/test/components/popup.test.tsx | 51 ++ .../web/test/components/profile-page.test.tsx | 191 +++++++ .../components/profile-review-card.test.tsx | 64 +++ .../web/test/components/profile-tabs.test.tsx | 61 ++ .../components/protected-layouts.test.tsx | 72 +++ .../test/components/report-button.test.tsx | 203 +++++++ .../review-actions-dialogue.test.tsx | 61 ++ .../components/review-card-stars.test.tsx | 55 ++ apps/web/test/components/review-card.test.tsx | 62 ++ .../test/components/review-form-page.test.tsx | 197 +++++++ .../web/test/components/review-modal.test.tsx | 362 ++++++++++++ .../test/components/review-section.test.tsx | 97 ++++ .../review-view-edit-modal.test.tsx | 329 +++++++++++ .../components/reviews-bar-graph.test.tsx | 32 ++ .../components/role-card-preview.test.tsx | 98 ++++ apps/web/test/components/role-info.test.tsx | 133 +++++ apps/web/test/components/role-page.test.tsx | 66 +++ .../components/role-type-selector.test.tsx | 54 ++ apps/web/test/components/roles-page.test.tsx | 282 +++++++++ .../test/components/round-bar-graph.test.tsx | 45 ++ .../components/screen-size-indicator.test.tsx | 38 ++ .../test/components/search-filter.test.tsx | 56 ++ .../shared-round-bar-graph.test.tsx | 43 ++ .../test/components/sidebar-filter.test.tsx | 248 ++++++++ .../test/components/sidebar-section.test.tsx | 104 ++++ .../components/simple-search-bar.test.tsx | 37 ++ apps/web/test/components/star-graph.test.tsx | 76 +++ apps/web/test/components/tab-toggle.test.tsx | 38 ++ .../web/test/components/themed-input.test.tsx | 25 + .../test/components/themed-select.test.tsx | 49 ++ .../components/tools-autocomplete.test.tsx | 90 +++ .../test/components/useFavoriteToggle.test.ts | 364 ++++++++++++ .../components/user-manager-table.test.tsx | 121 ++++ apps/web/test/dateHelpers.test.ts | 61 ++ apps/web/test/locationHelpers.test.ts | 121 ++++ apps/web/test/reviewAggregation.test.ts | 134 +++++ apps/web/test/setup.ts | 42 ++ apps/web/test/stringHelpers.test.ts | 124 ++++ apps/web/tsconfig.json | 2 +- apps/web/vitest.config.ts | 19 + package.json | 3 + packages/api/tests/admin.test.ts | 533 ++++++++++++++++++ packages/api/tests/auth.test.ts | 79 +++ packages/api/tests/company.test.ts | 318 +++++++++++ packages/api/tests/companytoLocation.test.ts | 55 ++ packages/api/tests/fuzzyHelper.test.ts | 33 ++ packages/api/tests/helpers.ts | 73 +++ packages/api/tests/index.test.ts | 29 + packages/api/tests/location.test.ts | 93 +++ packages/api/tests/profile.test.ts | 143 +++++ packages/api/tests/report.test.ts | 78 +++ packages/api/tests/review-extra.test.ts | 114 ++++ packages/api/tests/review-mutations.test.ts | 273 +++++++++ packages/api/tests/role-extra.test.ts | 269 +++++++++ packages/api/tests/role.test.ts | 191 +++++++ .../api/tests/roleAndCompany-list.test.ts | 223 ++++++++ packages/api/tests/roleAndCompany.test.ts | 76 +++ packages/api/tests/slugHelpers.test.ts | 37 ++ packages/api/tests/user.test.ts | 90 +++ packages/db/tests/companyRequest.test.ts | 138 +++++ packages/db/tests/enums.test.ts | 21 + packages/db/tests/profilesToCompanies.test.ts | 72 +++ packages/db/tests/profilesToRoles.test.ts | 70 +++ packages/db/tests/profliesToReviews.test.ts | 72 +++ packages/db/tsconfig.json | 2 +- packages/ui/tests/autocomplete.test.tsx | 171 ++++++ packages/ui/tests/custom-toaster.test.tsx | 56 ++ packages/ui/tests/dropdown-menu.test.tsx | 106 ++++ packages/ui/tests/logo.test.tsx | 46 ++ packages/ui/tests/pagination.test.tsx | 50 ++ packages/ui/tests/setup.ts | 9 + packages/ui/tests/success-toast.test.tsx | 62 ++ packages/ui/tests/toast.test.tsx | 85 +++ packages/ui/tests/use-custom-toast.test.ts | 50 ++ packages/ui/tests/use-toast.test.ts | 100 ++++ packages/ui/tsconfig.json | 2 +- packages/ui/vitest.config.ts | 12 + pnpm-lock.yaml | 114 +++- tooling/eslint/base.js | 24 +- vitest.config.ts | 46 ++ vitest.workspace.ts | 3 - 137 files changed, 13635 insertions(+), 290 deletions(-) delete mode 100644 apps/web/src/app/_components/reviews/unused/collapsable-info.tsx delete mode 100644 apps/web/src/app/_components/reviews/unused/info-card.tsx delete mode 100644 apps/web/src/app/_components/reviews/unused/new-role-card.tsx delete mode 100644 apps/web/src/app/_components/reviews/unused/review-search-bar.tsx delete mode 100644 apps/web/src/app/_components/search/review-search-bar.tsx create mode 100644 apps/web/test/api-routes.test.ts create mode 100644 apps/web/test/companyStatistics.test.ts create mode 100644 apps/web/test/components/admin-access-toast.test.tsx create mode 100644 apps/web/test/components/admin-layout.test.tsx create mode 100644 apps/web/test/components/admin-pages.test.tsx create mode 100644 apps/web/test/components/all-company-roles.test.tsx create mode 100644 apps/web/test/components/auth.test.tsx create mode 100644 apps/web/test/components/back-button.test.tsx create mode 100644 apps/web/test/components/bar-graph.test.tsx create mode 100644 apps/web/test/components/basic-info-section.test.tsx create mode 100644 apps/web/test/components/combo-box.test.tsx create mode 100644 apps/web/test/components/company-about.test.tsx create mode 100644 apps/web/test/components/company-card-preview.test.tsx create mode 100644 apps/web/test/components/company-details-section.test.tsx create mode 100644 apps/web/test/components/company-info.test.tsx create mode 100644 apps/web/test/components/company-reviews.test.tsx create mode 100644 apps/web/test/components/company-statistics.test.tsx create mode 100644 apps/web/test/components/compare-context.test.tsx create mode 100644 apps/web/test/components/compare-ui.test.tsx create mode 100644 apps/web/test/components/create-user-form.test.tsx create mode 100644 apps/web/test/components/dashboard-table.test.tsx create mode 100644 apps/web/test/components/delete-review-dialogue.test.tsx create mode 100644 apps/web/test/components/draft-review-card.test.tsx create mode 100644 apps/web/test/components/dropdown-filter.test.tsx create mode 100644 apps/web/test/components/dropdown-filters-bar.test.tsx create mode 100644 apps/web/test/components/existing-company-content.test.tsx create mode 100644 apps/web/test/components/favorite-button.test.tsx create mode 100644 apps/web/test/components/favorite-company-search.test.tsx create mode 100644 apps/web/test/components/favorite-role-search.test.tsx create mode 100644 apps/web/test/components/filter-body.test.tsx create mode 100644 apps/web/test/components/header-layout.test.tsx create mode 100644 apps/web/test/components/info-icon.test.tsx create mode 100644 apps/web/test/components/interview-modal.test.tsx create mode 100644 apps/web/test/components/interview-round-item.test.tsx create mode 100644 apps/web/test/components/interview-section.test.tsx create mode 100644 apps/web/test/components/landing-page.test.tsx create mode 100644 apps/web/test/components/layout.test.tsx create mode 100644 apps/web/test/components/loading-results.test.tsx create mode 100644 apps/web/test/components/logos.test.tsx create mode 100644 apps/web/test/components/mobile-header-button.test.tsx create mode 100644 apps/web/test/components/modal-container.test.tsx create mode 100644 apps/web/test/components/new-role-dialogue.test.tsx create mode 100644 apps/web/test/components/no-results.test.tsx create mode 100644 apps/web/test/components/not-found.test.tsx create mode 100644 apps/web/test/components/on-the-job-modal.test.tsx create mode 100644 apps/web/test/components/onboarding-dialog.test.tsx create mode 100644 apps/web/test/components/onboarding-form.test.tsx create mode 100644 apps/web/test/components/onboarding-wrapper.test.tsx create mode 100644 apps/web/test/components/pay-modal.test.tsx create mode 100644 apps/web/test/components/pip-bar.test.tsx create mode 100644 apps/web/test/components/pip-card.test.tsx create mode 100644 apps/web/test/components/popup.test.tsx create mode 100644 apps/web/test/components/profile-page.test.tsx create mode 100644 apps/web/test/components/profile-review-card.test.tsx create mode 100644 apps/web/test/components/profile-tabs.test.tsx create mode 100644 apps/web/test/components/protected-layouts.test.tsx create mode 100644 apps/web/test/components/report-button.test.tsx create mode 100644 apps/web/test/components/review-actions-dialogue.test.tsx create mode 100644 apps/web/test/components/review-card-stars.test.tsx create mode 100644 apps/web/test/components/review-card.test.tsx create mode 100644 apps/web/test/components/review-form-page.test.tsx create mode 100644 apps/web/test/components/review-modal.test.tsx create mode 100644 apps/web/test/components/review-section.test.tsx create mode 100644 apps/web/test/components/review-view-edit-modal.test.tsx create mode 100644 apps/web/test/components/reviews-bar-graph.test.tsx create mode 100644 apps/web/test/components/role-card-preview.test.tsx create mode 100644 apps/web/test/components/role-info.test.tsx create mode 100644 apps/web/test/components/role-page.test.tsx create mode 100644 apps/web/test/components/role-type-selector.test.tsx create mode 100644 apps/web/test/components/roles-page.test.tsx create mode 100644 apps/web/test/components/round-bar-graph.test.tsx create mode 100644 apps/web/test/components/screen-size-indicator.test.tsx create mode 100644 apps/web/test/components/search-filter.test.tsx create mode 100644 apps/web/test/components/shared-round-bar-graph.test.tsx create mode 100644 apps/web/test/components/sidebar-filter.test.tsx create mode 100644 apps/web/test/components/sidebar-section.test.tsx create mode 100644 apps/web/test/components/simple-search-bar.test.tsx create mode 100644 apps/web/test/components/star-graph.test.tsx create mode 100644 apps/web/test/components/tab-toggle.test.tsx create mode 100644 apps/web/test/components/themed-input.test.tsx create mode 100644 apps/web/test/components/themed-select.test.tsx create mode 100644 apps/web/test/components/tools-autocomplete.test.tsx create mode 100644 apps/web/test/components/useFavoriteToggle.test.ts create mode 100644 apps/web/test/components/user-manager-table.test.tsx create mode 100644 apps/web/test/dateHelpers.test.ts create mode 100644 apps/web/test/locationHelpers.test.ts create mode 100644 apps/web/test/reviewAggregation.test.ts create mode 100644 apps/web/test/setup.ts create mode 100644 apps/web/test/stringHelpers.test.ts create mode 100644 apps/web/vitest.config.ts create mode 100644 packages/api/tests/admin.test.ts create mode 100644 packages/api/tests/auth.test.ts create mode 100644 packages/api/tests/company.test.ts create mode 100644 packages/api/tests/companytoLocation.test.ts create mode 100644 packages/api/tests/fuzzyHelper.test.ts create mode 100644 packages/api/tests/helpers.ts create mode 100644 packages/api/tests/index.test.ts create mode 100644 packages/api/tests/location.test.ts create mode 100644 packages/api/tests/profile.test.ts create mode 100644 packages/api/tests/report.test.ts create mode 100644 packages/api/tests/review-extra.test.ts create mode 100644 packages/api/tests/review-mutations.test.ts create mode 100644 packages/api/tests/role-extra.test.ts create mode 100644 packages/api/tests/role.test.ts create mode 100644 packages/api/tests/roleAndCompany-list.test.ts create mode 100644 packages/api/tests/roleAndCompany.test.ts create mode 100644 packages/api/tests/slugHelpers.test.ts create mode 100644 packages/api/tests/user.test.ts create mode 100644 packages/db/tests/companyRequest.test.ts create mode 100644 packages/db/tests/enums.test.ts create mode 100644 packages/db/tests/profilesToCompanies.test.ts create mode 100644 packages/db/tests/profilesToRoles.test.ts create mode 100644 packages/db/tests/profliesToReviews.test.ts create mode 100644 packages/ui/tests/autocomplete.test.tsx create mode 100644 packages/ui/tests/custom-toaster.test.tsx create mode 100644 packages/ui/tests/dropdown-menu.test.tsx create mode 100644 packages/ui/tests/logo.test.tsx create mode 100644 packages/ui/tests/pagination.test.tsx create mode 100644 packages/ui/tests/setup.ts create mode 100644 packages/ui/tests/success-toast.test.tsx create mode 100644 packages/ui/tests/toast.test.tsx create mode 100644 packages/ui/tests/use-custom-toast.test.ts create mode 100644 packages/ui/tests/use-toast.test.ts create mode 100644 packages/ui/vitest.config.ts create mode 100644 vitest.config.ts delete mode 100644 vitest.workspace.ts diff --git a/apps/web/src/app/_components/reviews/unused/collapsable-info.tsx b/apps/web/src/app/_components/reviews/unused/collapsable-info.tsx deleted file mode 100644 index 3980c97a..00000000 --- a/apps/web/src/app/_components/reviews/unused/collapsable-info.tsx +++ /dev/null @@ -1,49 +0,0 @@ -import type { ReactNode } from "react"; -import React, { useState } from "react"; - -interface CollapsableInfoCardProps { - title: string; - children: ReactNode; -} - -const CollapsableInfoCard: React.FC = ({ - title, - children, -}) => { - const [isExpanded, setIsExpanded] = useState(true); - - return ( -
- -
-
{children}
-
-
- ); -}; - -export default CollapsableInfoCard; diff --git a/apps/web/src/app/_components/reviews/unused/info-card.tsx b/apps/web/src/app/_components/reviews/unused/info-card.tsx deleted file mode 100644 index 201db28d..00000000 --- a/apps/web/src/app/_components/reviews/unused/info-card.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import type { ReactNode } from "react"; -import React from "react"; - -interface InfoCardProps { - title: string; - children: ReactNode; -} - -const InfoCard: React.FC = ({ title, children }) => { - return ( -
-
- {title} -
-
{children}
-
- ); -}; - -export default InfoCard; diff --git a/apps/web/src/app/_components/reviews/unused/new-role-card.tsx b/apps/web/src/app/_components/reviews/unused/new-role-card.tsx deleted file mode 100644 index 0b1b1b4b..00000000 --- a/apps/web/src/app/_components/reviews/unused/new-role-card.tsx +++ /dev/null @@ -1,38 +0,0 @@ -"use client"; - -import { useEffect, useState } from "react"; - -import { Card, CardContent, CardHeader, CardTitle } from "@cooper/ui/card"; - -import { api } from "~/trpc/react"; -import NewRoleDialog from "../../roles/new-role-dialogue"; - -interface NewRoleCardProps { - companyId: string; -} - -export default function NewRoleCard({ companyId }: NewRoleCardProps) { - const [authorized, setAuthorized] = useState(false); - const { data: session } = api.auth.getSession.useQuery(); - - useEffect(() => { - setAuthorized(!!session); - }, [setAuthorized, session]); - - return ( - - - - {authorized ? "Don't see your role?" : "Sign in to create a new role"} - - - - - - - ); -} diff --git a/apps/web/src/app/_components/reviews/unused/review-search-bar.tsx b/apps/web/src/app/_components/reviews/unused/review-search-bar.tsx deleted file mode 100644 index c2ddbb70..00000000 --- a/apps/web/src/app/_components/reviews/unused/review-search-bar.tsx +++ /dev/null @@ -1,28 +0,0 @@ -"use client"; - -import { Input } from "../../themed/onboarding/input"; - -interface ReviewSearchBarProps { - searchTerm: string; - onSearchChange: (value: string) => void; - className?: string; -} - -export default function ReviewSearchBar({ - searchTerm, - onSearchChange, - className, -}: ReviewSearchBarProps) { - return ( -
- { - onSearchChange(e.target.value); - }} - className="!h-10 w-full !border-[0.75px] !border-cooper-gray-400 bg-cooper-gray-100 !text-sm focus:ring-1 focus:ring-cooper-gray-400 focus:ring-offset-0" - placeholder="Search reviews..." - /> -
- ); -} diff --git a/apps/web/src/app/_components/search/review-search-bar.tsx b/apps/web/src/app/_components/search/review-search-bar.tsx deleted file mode 100644 index f14efebb..00000000 --- a/apps/web/src/app/_components/search/review-search-bar.tsx +++ /dev/null @@ -1,144 +0,0 @@ -import { useState } from "react"; -import { useFormContext } from "react-hook-form"; - -import { Button } from "@cooper/ui/button"; -import { FormControl, FormField, FormItem } from "@cooper/ui/form"; -import { Input } from "@cooper/ui/input"; -import { - Select, - SelectContent, - SelectGroup, - SelectItem, - SelectSeparator, - SelectTrigger, - SelectValue, -} from "@cooper/ui/select"; - -interface SearchBarProps { - cycle?: "FALL" | "SPRING" | "SUMMER"; - term?: "INPERSON" | "HYBRID" | "REMOTE"; -} - -/** - * This Search Bar employs filtering and fuzzy searching. - * - * NOTE: Cycle and Term only make sense for Roles - * - * @param param0 Cycle and Term to be set as default values for their respective dropdowns - * @returns A search bar which is connected to a parent 'useForm' - */ -export function ReviewSearchBar({ cycle, term }: SearchBarProps) { - const form = useFormContext(); - - const [selectedCycle, setSelectedCycle] = useState(cycle); - const [selectedTerm, setSelectedTerm] = useState(term); - - return ( -
- ( - - - - - - )} - /> - ( - - - - - - )} - /> - ( - - - - - - )} - /> - -
- ); -} diff --git a/apps/web/test/api-routes.test.ts b/apps/web/test/api-routes.test.ts new file mode 100644 index 00000000..7a3f3997 --- /dev/null +++ b/apps/web/test/api-routes.test.ts @@ -0,0 +1,55 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; + +const h = vi.hoisted(() => ({ + fetchRequestHandler: vi.fn(), + toNextJsHandler: vi.fn(() => ({ GET: vi.fn(), POST: vi.fn() })), + getSession: vi.fn(), + auth: {}, +})); + +vi.mock("@trpc/server/adapters/fetch", () => ({ + fetchRequestHandler: h.fetchRequestHandler, +})); +vi.mock("better-auth/next-js", () => ({ toNextJsHandler: h.toNextJsHandler })); +vi.mock("@cooper/api", () => ({ + appRouter: {}, + createTRPCContext: vi.fn(), +})); +vi.mock("@cooper/auth", () => ({ + auth: { api: { getSession: h.getSession } }, +})); + +describe("api/auth/[...all] route", () => { + test("exposes GET and POST handlers from better-auth", async () => { + const route = await import("~/app/api/auth/[...all]/route"); + expect(route.GET).toBeTypeOf("function"); + expect(route.POST).toBeTypeOf("function"); + expect(h.toNextJsHandler).toHaveBeenCalled(); + }); +}); + +describe("api/trpc/[trpc] route", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + test("OPTIONS responds 204 with permissive CORS headers", async () => { + const route = await import("~/app/api/trpc/[trpc]/route"); + const res = route.OPTIONS(); + expect(res.status).toBe(204); + expect(res.headers.get("Access-Control-Allow-Origin")).toBe("*"); + expect(res.headers.get("Access-Control-Allow-Methods")).toBe( + "OPTIONS, GET, POST", + ); + }); + + test("GET delegates to fetchRequestHandler and sets CORS headers", async () => { + h.fetchRequestHandler.mockResolvedValue(new Response("ok")); + const route = await import("~/app/api/trpc/[trpc]/route"); + + const res = await route.GET(new Request("http://localhost/api/trpc")); + + expect(h.fetchRequestHandler).toHaveBeenCalledOnce(); + expect(res.headers.get("Access-Control-Allow-Origin")).toBe("*"); + }); +}); diff --git a/apps/web/test/companyStatistics.test.ts b/apps/web/test/companyStatistics.test.ts new file mode 100644 index 00000000..0a6b2fd7 --- /dev/null +++ b/apps/web/test/companyStatistics.test.ts @@ -0,0 +1,126 @@ +import { describe, expect, test, vi } from "vitest"; + +import type { ReviewType } from "@cooper/db/schema"; + +// companyStatistics -> stringHelpers -> @cooper/ui; stub the barrel so the +// untransformable .tsx components aren't pulled into the test graph. +vi.mock("@cooper/ui", () => ({ + cn: (...inputs: unknown[]) => inputs.flat().filter(Boolean).join(" "), +})); + +import { + calculateJobTypes, + calculatePay, + calculatePayRange, + calculateWorkModels, +} from "~/utils/companyStatistics"; + +const review = (overrides: Partial = {}): ReviewType => + ({ + workEnvironment: "REMOTE", + jobType: "CO_OP", + hourlyPay: "20", + ...overrides, + }) as ReviewType; + +describe("calculateWorkModels", () => { + test("returns empty array when given no reviews", () => { + expect(calculateWorkModels()).toEqual([]); + }); + + test("computes count and percentage per unique work model", () => { + const result = calculateWorkModels([ + review({ workEnvironment: "REMOTE" }), + review({ workEnvironment: "REMOTE" }), + review({ workEnvironment: "HYBRID" }), + review({ workEnvironment: "INPERSON" }), + ]); + expect(result).toContainEqual({ name: "Remote", percentage: 50, count: 2 }); + expect(result).toContainEqual({ name: "Hybrid", percentage: 25, count: 1 }); + expect(result).toContainEqual({ + name: "In-person", + percentage: 25, + count: 1, + }); + }); +}); + +describe("calculateJobTypes", () => { + test("returns empty array when given no reviews", () => { + expect(calculateJobTypes()).toEqual([]); + }); + + test("camel-cases the job type names and computes percentages", () => { + const result = calculateJobTypes([ + review({ jobType: "coop" }), + review({ jobType: "coop" }), + review({ jobType: "internship" }), + ]); + expect(result).toContainEqual({ name: "Coop", percentage: 67, count: 2 }); + expect(result).toContainEqual({ + name: "Internship", + percentage: 33, + count: 1, + }); + }); +}); + +describe("calculatePay", () => { + test("returns empty array when no reviews have valid pay", () => { + expect( + calculatePay([review({ hourlyPay: "" }), review({ hourlyPay: null })]), + ).toEqual([]); + }); + + test("groups reviews by pay value", () => { + const result = calculatePay([ + review({ hourlyPay: "20" }), + review({ hourlyPay: "20" }), + review({ hourlyPay: "30" }), + ]); + expect(result).toContainEqual({ pay: "20", percentage: 67, count: 2 }); + expect(result).toContainEqual({ pay: "30", percentage: 33, count: 1 }); + }); +}); + +describe("calculatePayRange", () => { + test("returns a zero range when there is no valid pay", () => { + expect(calculatePayRange([review({ hourlyPay: "0" })])).toEqual([ + { min: 0, max: 0 }, + ]); + }); + + test("returns a single 'Pay' bucket when all pays are equal", () => { + expect( + calculatePayRange([ + review({ hourlyPay: "25" }), + review({ hourlyPay: "25" }), + ]), + ).toEqual([{ label: "Pay", min: 25, max: 25 }]); + }); + + test("splits into Low/Mid buckets for two unique pays", () => { + const result = calculatePayRange([ + review({ hourlyPay: "10" }), + review({ hourlyPay: "20" }), + ]); + expect(result).toEqual([ + { label: "Low", min: 10, max: 15 }, + { label: "Mid", min: 16, max: 20 }, + ]); + }); + + test("splits into Low/Mid/High buckets for three or more unique pays", () => { + const result = calculatePayRange([ + review({ hourlyPay: "10" }), + review({ hourlyPay: "20" }), + review({ hourlyPay: "40" }), + ]); + expect(result).toHaveLength(3); + expect(result[0]?.label).toBe("Low"); + expect(result[1]?.label).toBe("Mid"); + expect(result[2]?.label).toBe("High"); + expect(result[0]?.min).toBe(10); + expect(result[2]?.max).toBe(40); + }); +}); diff --git a/apps/web/test/components/admin-access-toast.test.tsx b/apps/web/test/components/admin-access-toast.test.tsx new file mode 100644 index 00000000..120018dc --- /dev/null +++ b/apps/web/test/components/admin-access-toast.test.tsx @@ -0,0 +1,75 @@ +import { render } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; + +const h = vi.hoisted(() => ({ + error: vi.fn(), + replace: vi.fn(), + params: new URLSearchParams(), +})); + +vi.mock("next/navigation", () => ({ + useSearchParams: () => h.params, + useRouter: () => ({ replace: h.replace }), +})); + +vi.mock("@cooper/ui", () => ({ + useCustomToast: () => ({ toast: { error: h.error } }), +})); + +import { AdminAccessToast } from "~/app/_components/landing/admin-access-toast"; + +const CLEAR_URL_AFTER_MS = 5000; + +describe("AdminAccessToast", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.useFakeTimers(); + h.params = new URLSearchParams(); + }); + + afterEach(() => { + vi.runOnlyPendingTimers(); + vi.useRealTimers(); + }); + + test("does nothing when there is no error param", () => { + render(); + expect(h.error).not.toHaveBeenCalled(); + }); + + test("does nothing for an unrecognized error param", () => { + h.params = new URLSearchParams({ error: "something-else" }); + render(); + expect(h.error).not.toHaveBeenCalled(); + }); + + test("shows the mapped toast for an unauthorized-admin error", () => { + h.params = new URLSearchParams({ error: "unauthorized-admin" }); + render(); + expect(h.error).toHaveBeenCalledWith( + "You don't have access as an admin or coordinator.", + ); + }); + + test("shows the mapped toast for a disabled-account error", () => { + h.params = new URLSearchParams({ error: "disabled-account" }); + render(); + expect(h.error).toHaveBeenCalledWith("Your access has been disabled."); + }); + + test("clears the url after the timeout elapses", () => { + h.params = new URLSearchParams({ error: "disabled-account" }); + render(); + expect(h.replace).not.toHaveBeenCalled(); + + vi.advanceTimersByTime(CLEAR_URL_AFTER_MS); + expect(h.replace).toHaveBeenCalledWith("/", { scroll: false }); + }); + + test("shows the toast only once across re-renders", () => { + h.params = new URLSearchParams({ error: "disabled-account" }); + const { rerender } = render(); + rerender(); + expect(h.error).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/web/test/components/admin-layout.test.tsx b/apps/web/test/components/admin-layout.test.tsx new file mode 100644 index 00000000..4552c0a0 --- /dev/null +++ b/apps/web/test/components/admin-layout.test.tsx @@ -0,0 +1,87 @@ +import { render, screen } from "@testing-library/react"; +import { beforeEach, describe, expect, test, vi } from "vitest"; + +const replace = vi.fn(); +let sessionQuery: { + data?: { user: { role: string } }; + isLoading: boolean; + error: null; +}; + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ replace }), +})); + +vi.mock("~/trpc/react", () => ({ + api: { + auth: { + getSession: { useQuery: () => sessionQuery }, + }, + }, +})); + +import AdminLayout from "~/app/(pages)/(protected)/admin/layout"; + +describe("AdminLayout", () => { + beforeEach(() => { + vi.clearAllMocks(); + sessionQuery = { data: undefined, isLoading: true, error: null }; + }); + + test("renders nav links and children for an admin", () => { + sessionQuery = { + data: { user: { role: "ADMIN" } }, + isLoading: false, + error: null, + }; + render( + +
+ , + ); + expect(screen.getByText("Dashboard")).toBeInTheDocument(); + expect(screen.getByText("User Manager")).toBeInTheDocument(); + expect(screen.getByTestId("child")).toBeInTheDocument(); + expect(replace).not.toHaveBeenCalled(); + }); + + test("renders for a developer without redirecting", () => { + sessionQuery = { + data: { user: { role: "DEVELOPER" } }, + isLoading: false, + error: null, + }; + render( + +
+ , + ); + expect(screen.getByTestId("child")).toBeInTheDocument(); + expect(replace).not.toHaveBeenCalled(); + }); + + test("redirects non-admins to /404", () => { + sessionQuery = { + data: { user: { role: "STUDENT" } }, + isLoading: false, + error: null, + }; + const { container } = render( + +
+ , + ); + expect(replace).toHaveBeenCalledWith("/404"); + expect(container).toBeEmptyDOMElement(); + }); + + test("does not redirect while the session is still loading", () => { + sessionQuery = { data: undefined, isLoading: true, error: null }; + render( + +
+ , + ); + expect(replace).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/test/components/admin-pages.test.tsx b/apps/web/test/components/admin-pages.test.tsx new file mode 100644 index 00000000..e89208a4 --- /dev/null +++ b/apps/web/test/components/admin-pages.test.tsx @@ -0,0 +1,30 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, test, vi } from "vitest"; + +vi.mock("~/app/_components/admin/dashboard-table", () => ({ + AdminDashboardTable: () =>
, +})); +vi.mock("~/app/_components/admin/create-user-form", () => ({ + CreateUserForm: () =>
, +})); +vi.mock("~/app/_components/admin/user-manager-table", () => ({ + AdminUserManagerTable: () =>
, +})); + +import AdminDashboardPage from "~/app/(pages)/(protected)/admin/dashboard/page"; +import AdminUserManagerPage from "~/app/(pages)/(protected)/admin/user-manager/page"; + +describe("AdminDashboardPage", () => { + test("renders the dashboard table", () => { + render(); + expect(screen.getByTestId("dashboard-table")).toBeInTheDocument(); + }); +}); + +describe("AdminUserManagerPage", () => { + test("renders the create-user form and the user-manager table", () => { + render(); + expect(screen.getByTestId("create-user-form")).toBeInTheDocument(); + expect(screen.getByTestId("user-manager-table")).toBeInTheDocument(); + }); +}); diff --git a/apps/web/test/components/all-company-roles.test.tsx b/apps/web/test/components/all-company-roles.test.tsx new file mode 100644 index 00000000..ec838c77 --- /dev/null +++ b/apps/web/test/components/all-company-roles.test.tsx @@ -0,0 +1,93 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { beforeEach, describe, expect, test, vi } from "vitest"; + +import type { CompanyType } from "@cooper/db/schema"; + +const push = vi.fn(); +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push }), +})); + +let rolesQuery: { + data?: { id: string; slug: string }[]; + isPending: boolean; + isSuccess: boolean; +}; + +vi.mock("~/trpc/react", () => ({ + api: { + role: { + getByCompany: { useQuery: () => rolesQuery }, + }, + }, +})); + +vi.mock("@cooper/ui", () => ({ + cn: (...inputs: unknown[]) => inputs.flat().filter(Boolean).join(" "), +})); + +vi.mock("~/app/_components/loading-results", () => ({ + default: () =>
, +})); + +vi.mock("~/app/_components/roles/role-card-preview", () => ({ + RoleCardPreview: ({ roleObj }: { roleObj: { slug: string } }) => ( +
{roleObj.slug}
+ ), +})); + +import RenderAllRoles from "~/app/_components/companies/all-company-roles"; + +const company = { + id: "c1", + name: "Acme Corp", + slug: "acme", +} as unknown as CompanyType; + +beforeEach(() => { + vi.clearAllMocks(); + rolesQuery = { + data: [ + { id: "r1", slug: "engineer" }, + { id: "r2", slug: "designer" }, + ], + isPending: false, + isSuccess: true, + }; +}); + +describe("RenderAllRoles", () => { + test("renders heading with company name and role count", () => { + render(); + expect(screen.getByText(/Roles at Acme Corp/)).toBeInTheDocument(); + expect(screen.getAllByTestId("role-card")).toHaveLength(2); + }); + + test("shows loading state while pending", () => { + rolesQuery = { data: undefined, isPending: true, isSuccess: false }; + render(); + expect(screen.getByTestId("loading")).toBeInTheDocument(); + }); + + test("renders no cards when there are no roles", () => { + rolesQuery = { data: [], isPending: false, isSuccess: true }; + render(); + expect(screen.queryByTestId("role-card")).toBeNull(); + }); + + test("navigates and calls onClose when a role is clicked", () => { + const onClose = vi.fn(); + render(); + fireEvent.click(screen.getByText("engineer")); + expect(onClose).toHaveBeenCalled(); + expect(push).toHaveBeenCalledWith( + "/roles/?company=acme&type=roles&role=engineer", + ); + }); + + test("handles a null company without crashing", () => { + rolesQuery = { data: [], isPending: false, isSuccess: true }; + render(); + expect(screen.getByText(/Roles at/)).toBeInTheDocument(); + }); +}); diff --git a/apps/web/test/components/auth.test.tsx b/apps/web/test/components/auth.test.tsx new file mode 100644 index 00000000..8241344b --- /dev/null +++ b/apps/web/test/components/auth.test.tsx @@ -0,0 +1,111 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, test, vi } from "vitest"; + +const oauth2 = vi.fn().mockResolvedValue({ error: null }); +const signOutClient = vi.fn().mockResolvedValue(undefined); +vi.mock("@cooper/auth/client", () => ({ + authClient: { + signIn: { oauth2: (...args: unknown[]) => oauth2(...args) }, + signOut: () => signOutClient(), + }, +})); + +const signIn = vi.fn(); +const signInAsPreviewUser = vi.fn(); +const signOut = vi.fn(); +vi.mock("@cooper/auth", () => ({ + signIn: (...args: unknown[]) => signIn(...args), + signInAsPreviewUser: (...args: unknown[]) => signInAsPreviewUser(...args), + signOut: (...args: unknown[]) => signOut(...args), +})); + +const handleGoogleSignIn = vi.fn(); +const handlePreviewSignIn = vi.fn(); +vi.mock("~/app/_components/auth/actions", () => ({ + handleGoogleSignIn: () => handleGoogleSignIn(), + handlePreviewSignIn: () => handlePreviewSignIn(), +})); + +import AdminSignInButton from "~/app/_components/auth/admin-signin-button"; +import LoginButtonClient from "~/app/_components/auth/login-button-client"; +import LoginButton from "~/app/_components/auth/login-button"; +import LogoutButton from "~/app/_components/auth/logout-button"; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("auth server actions", () => { + // The real actions module is exercised separately from the mocked import + // above by importing it directly inside these tests. + test("handleGoogleSignIn signs in with google", async () => { + const actions = await vi.importActual< + typeof import("~/app/_components/auth/actions") + >("~/app/_components/auth/actions"); + await actions.handleGoogleSignIn(); + expect(signIn).toHaveBeenCalledWith("google", { redirectTo: "/" }); + }); + + test("handlePreviewSignIn signs in as preview user", async () => { + const actions = await vi.importActual< + typeof import("~/app/_components/auth/actions") + >("~/app/_components/auth/actions"); + await actions.handlePreviewSignIn(); + expect(signInAsPreviewUser).toHaveBeenCalledWith({ redirectTo: "/" }); + }); + + test("handleSignOut signs out", async () => { + const actions = await vi.importActual< + typeof import("~/app/_components/auth/actions") + >("~/app/_components/auth/actions"); + await actions.handleSignOut(); + expect(signOut).toHaveBeenCalledWith({ redirectTo: "/" }); + }); +}); + +describe("AdminSignInButton", () => { + test("starts an admin oauth2 sign-in on click", async () => { + render(); + fireEvent.click(screen.getByRole("button", { name: /continue as admin/i })); + await waitFor(() => + expect(oauth2).toHaveBeenCalledWith({ + providerId: "googleAdmin", + callbackURL: "/roles", + }), + ); + }); +}); + +describe("LoginButtonClient", () => { + test("renders the google sign-in image button", () => { + render(); + expect(screen.getByAltText("Login")).toBeInTheDocument(); + }); +}); + +describe("LoginButton", () => { + test("renders the preview variant", () => { + render(); + expect(screen.getByText("Sign in as preview user")).toBeInTheDocument(); + }); + + test("renders the default variant and triggers oauth2", async () => { + render(); + expect(screen.getByText("Log in with Husky email")).toBeInTheDocument(); + fireEvent.click(screen.getByText("Log in with Husky email")); + await waitFor(() => + expect(oauth2).toHaveBeenCalledWith({ + providerId: "google", + callbackURL: "/roles", + }), + ); + }); +}); + +describe("LogoutButton", () => { + test("signs out on click", async () => { + render(); + fireEvent.click(screen.getByRole("button", { name: "Sign Out" })); + await waitFor(() => expect(signOutClient).toHaveBeenCalledOnce()); + }); +}); diff --git a/apps/web/test/components/back-button.test.tsx b/apps/web/test/components/back-button.test.tsx new file mode 100644 index 00000000..590ac940 --- /dev/null +++ b/apps/web/test/components/back-button.test.tsx @@ -0,0 +1,19 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, test, vi } from "vitest"; + +const back = vi.fn(); +vi.mock("next/navigation", () => ({ + useRouter: () => ({ back }), +})); + +import BackButton from "~/app/_components/back-button"; + +describe("BackButton", () => { + test("renders a Go Back button and navigates back on click", () => { + render(); + const button = screen.getByRole("button", { name: "Go Back" }); + expect(button).toBeInTheDocument(); + fireEvent.click(button); + expect(back).toHaveBeenCalledOnce(); + }); +}); diff --git a/apps/web/test/components/bar-graph.test.tsx b/apps/web/test/components/bar-graph.test.tsx new file mode 100644 index 00000000..e7fa0641 --- /dev/null +++ b/apps/web/test/components/bar-graph.test.tsx @@ -0,0 +1,23 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, test } from "vitest"; + +import BarGraph from "~/app/_components/shared/bar-graph"; + +describe("BarGraph", () => { + test("renders the title and value", () => { + render(); + expect(screen.getByText("Culture")).toBeDefined(); + // value.toPrecision(2) + expect(screen.getByText("4.0")).toBeDefined(); + }); + + test("renders the industry average line and label when provided", () => { + render(); + expect(screen.getByText("Industry average: 2.5")).toBeDefined(); + }); + + test("does not render the industry average label when omitted", () => { + render(); + expect(screen.queryByText(/Industry average/)).toBeNull(); + }); +}); diff --git a/apps/web/test/components/basic-info-section.test.tsx b/apps/web/test/components/basic-info-section.test.tsx new file mode 100644 index 00000000..c271cd62 --- /dev/null +++ b/apps/web/test/components/basic-info-section.test.tsx @@ -0,0 +1,178 @@ +import type { ReactNode } from "react"; +import { fireEvent, render, screen, within } from "@testing-library/react"; +import { FormProvider, useForm } from "react-hook-form"; +import { describe, expect, test, vi } from "vitest"; + +vi.mock("~/app/_components/filters/filter-body", () => ({ + default: ({ + title, + options, + onSelectionChange, + }: { + title: string; + options?: { id: string; label: string }[]; + onSelectionChange?: (selected: string[]) => void; + }) => ( +
+ {options?.map((o) => ( + + ))} +
+ ), +})); +vi.mock("~/app/_components/reviews/existing-company-content", () => ({ + default: () =>
, +})); +vi.mock("~/app/_components/location", () => ({ + default: () =>
, +})); +vi.mock("~/app/_components/themed/onboarding/input", () => ({ + Input: ({ + onClear: _onClear, + ...props + }: Record & { onClear?: () => void }) => ( + + ), +})); + +const popularityQuery = { + data: [ + { id: "loc-1", city: "Boston", state: "MA", country: "USA" }, + { id: "loc-2", city: "London", state: null, country: "UK" }, + ], + isSuccess: true, +}; +const byIdQuery = { + data: { id: "loc-1", city: "Boston", state: "MA", country: "USA" }, + isSuccess: true, +}; +vi.mock("~/trpc/react", () => ({ + api: { + location: { + getByPopularity: { useQuery: () => popularityQuery }, + getById: { useQuery: () => byIdQuery }, + }, + }, +})); + +import { BasicInfoSection } from "~/app/_components/form/sections/basic-info-section"; + +type Defaults = Record; + +const baseDefaults: Defaults = { + jobType: "", + workTerm: "", + jobLength: null, + workYear: undefined, + locationId: "loc-1", +}; + +function Wrapper({ + children, + defaults = baseDefaults, +}: { + children: ReactNode; + defaults?: Defaults; +}) { + const form = useForm({ defaultValues: defaults }); + return {children}; +} + +function Probe({ defaults = baseDefaults }: { defaults?: Defaults }) { + const form = useForm({ defaultValues: defaults }); + return ( + + + {String(form.watch("jobType"))} + {String(form.watch("workTerm"))} + + {String(form.watch("jobLength"))} + + {String(form.watch("workYear"))} + + ); +} + +describe("BasicInfoSection", () => { + test("renders the core field labels", () => { + render( + + + , + ); + expect(screen.getByText("Job type")).toBeInTheDocument(); + expect(screen.getByText(/Co-op\/internship term/)).toBeInTheDocument(); + expect(screen.getByText("Job length")).toBeInTheDocument(); + expect( + screen.getByText(/Year of co-op\/internship term/), + ).toBeInTheDocument(); + expect(screen.getByText("Location")).toBeInTheDocument(); + }); + + test("renders the existing-company content and the location box", () => { + render( + + + , + ); + expect(screen.getByTestId("existing-company")).toBeInTheDocument(); + expect(screen.getByTestId("location-box")).toBeInTheDocument(); + }); + + test("selects the job type", () => { + render(); + fireEvent.click(screen.getByTestId("fb-Job type-Co-op")); + expect(screen.getByTestId("v-jobType")).toHaveTextContent("Co-op"); + }); + + test("selects the work term", () => { + render(); + fireEvent.click(screen.getByTestId("fb-Co-op/internship term-SPRING")); + expect(screen.getByTestId("v-workTerm")).toHaveTextContent("SPRING"); + }); + + test("parses the job length and clears it to null", () => { + render(); + const input = screen.getByTestId("themed-input"); + fireEvent.change(input, { target: { value: "6" } }); + expect(screen.getByTestId("v-jobLength")).toHaveTextContent("6"); + fireEvent.change(input, { target: { value: "" } }); + expect(screen.getByTestId("v-jobLength")).toHaveTextContent("null"); + }); + + test("selects a work year as a number", () => { + render(); + const yearBody = screen + .getAllByTestId("filter-body") + .find((el) => el.getAttribute("data-title") === "Year")!; + const firstYear = within(yearBody).getAllByRole("button")[0]!; + fireEvent.click(firstYear); + expect(screen.getByTestId("v-workYear")).not.toHaveTextContent("undefined"); + expect( + Number(screen.getByTestId("v-workYear").textContent), + ).toBeGreaterThan(1999); + }); + + test("resets a work year that is beyond the allowed maximum", () => { + render(); + // The mount effect clamps an out-of-range year back to undefined. + expect(screen.getByTestId("v-workYear")).toHaveTextContent("undefined"); + }); + + test("populates the location label from the fetched location", () => { + // getById returns Boston, MA so the effect derives the label without error. + render( + + + , + ); + expect(screen.getByTestId("location-box")).toBeInTheDocument(); + }); +}); diff --git a/apps/web/test/components/combo-box.test.tsx b/apps/web/test/components/combo-box.test.tsx new file mode 100644 index 00000000..feb5d365 --- /dev/null +++ b/apps/web/test/components/combo-box.test.tsx @@ -0,0 +1,60 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, test, vi } from "vitest"; + +import ComboBox from "~/app/_components/combo-box"; + +const options = [ + { value: "a", label: "Apple" }, + { value: "b", label: "Banana" }, +]; + +function setup(overrides = {}) { + const onSelect = vi.fn(); + const onClear = vi.fn(); + render( + , + ); + return { onSelect, onClear }; +} + +describe("ComboBox", () => { + test("shows the default label when nothing is selected", () => { + setup(); + expect(screen.getByRole("combobox")).toHaveTextContent("Fruit"); + }); + + test("shows the current label when one is selected", () => { + setup({ currLabel: "Banana" }); + expect(screen.getByRole("combobox")).toHaveTextContent("Banana"); + }); + + test("renders the clear button and fires onClear", () => { + const onClear = vi.fn(); + setup({ onClear }); + fireEvent.click(screen.getByLabelText("Clear selection")); + expect(onClear).toHaveBeenCalledOnce(); + }); + + test("renders the form variant", () => { + setup({ variant: "form" }); + expect(screen.getByRole("combobox")).toBeInTheDocument(); + }); + + test("renders the filtering variant", () => { + setup({ variant: "filtering" }); + expect(screen.getByRole("combobox")).toBeInTheDocument(); + }); + + test("renders the newForm styling", () => { + setup({ newForm: true }); + expect(screen.getByRole("combobox")).toBeInTheDocument(); + }); +}); diff --git a/apps/web/test/components/company-about.test.tsx b/apps/web/test/components/company-about.test.tsx new file mode 100644 index 00000000..e99c6d0b --- /dev/null +++ b/apps/web/test/components/company-about.test.tsx @@ -0,0 +1,26 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, test } from "vitest"; + +import type { CompanyType } from "@cooper/db/schema"; + +import { CompanyAbout } from "~/app/_components/companies/company-about"; + +const company = { + id: "c1", + name: "Acme Corp", + description: "We build anvils.", +} as unknown as CompanyType; + +describe("CompanyAbout", () => { + test("renders the company name and description", () => { + render(); + expect(screen.getByText("About Acme Corp")).toBeInTheDocument(); + expect(screen.getByText("We build anvils.")).toBeInTheDocument(); + }); + + test("renders gracefully when companyObj is undefined", () => { + render(); + // "About " with no name still renders the heading text node. + expect(screen.getByText(/About/)).toBeInTheDocument(); + }); +}); diff --git a/apps/web/test/components/company-card-preview.test.tsx b/apps/web/test/components/company-card-preview.test.tsx new file mode 100644 index 00000000..699c4254 --- /dev/null +++ b/apps/web/test/components/company-card-preview.test.tsx @@ -0,0 +1,97 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { beforeEach, describe, expect, test, vi } from "vitest"; + +import type { CompanyType } from "@cooper/db/schema"; + +let locationsData: { location: { city: string; state: string } }[] | undefined; +let avgData: { averageOverallRating: number | string } | undefined; +let reviewsData: unknown[] | undefined; + +vi.mock("~/trpc/react", () => ({ + api: { + companyToLocation: { + getLocationsByCompanyId: { + useQuery: () => ({ data: locationsData }), + }, + }, + company: { + getAverageById: { useQuery: () => ({ data: avgData }) }, + }, + review: { + getByCompany: { useQuery: () => ({ data: reviewsData }) }, + }, + }, +})); + +vi.mock("@cooper/ui", () => ({ + cn: (...inputs: unknown[]) => inputs.flat().filter(Boolean).join(" "), +})); + +vi.mock("@cooper/ui/logo", () => ({ + default: () =>
, +})); + +vi.mock("~/app/_components/shared/favorite-button", () => ({ + FavoriteButton: () => + ))} +
+ ), +})); +vi.mock("~/app/_components/form/sections/tools-autocomplete", () => ({ + ToolsAutocomplete: () =>
, +})); +vi.mock("~/app/_components/themed/onboarding/input", () => ({ + Input: ({ + onClear: _onClear, + ...props + }: Record & { onClear?: () => void }) => ( + + ), +})); + +import { CompanyDetailsSection } from "~/app/_components/form/sections/company-details-section"; + +type Defaults = Record; + +const baseDefaults: Defaults = { + workEnvironment: "", + workHours: null, + federalHolidays: "", + drugTest: "", + accessibleByTransportation: "", + teamOutings: false, + coffeeChats: false, + constructiveFeedback: false, + onboarding: false, + workStructure: false, + careerGrowth: false, + freeLunch: false, + travelBenefits: false, + freeMerch: false, + snackBar: false, + toolNames: [], +}; + +function Wrapper({ + children, + defaults = baseDefaults, +}: { + children: ReactNode; + defaults?: Defaults; +}) { + const form = useForm({ defaultValues: defaults }); + return {children}; +} + +function Probe({ defaults = baseDefaults }: { defaults?: Defaults }) { + const form = useForm({ defaultValues: defaults }); + return ( + + + + {String(form.watch("workEnvironment"))} + + + {String(form.watch("workHours"))} + + + {String(form.watch("freeLunch"))} + + + ); +} + +describe("CompanyDetailsSection", () => { + test("renders the primary field labels", () => { + render( + + + , + ); + expect(screen.getByText("Work model")).toBeInTheDocument(); + expect(screen.getByText("Work hours")).toBeInTheDocument(); + expect(screen.getByText("Federal holidays off")).toBeInTheDocument(); + expect(screen.getByText("Drug test required")).toBeInTheDocument(); + expect( + screen.getByText("Accessible by transportation"), + ).toBeInTheDocument(); + expect(screen.getByText("Tools and software")).toBeInTheDocument(); + }); + + test("renders company-culture and co-op support checkboxes", () => { + render( + + + , + ); + expect(screen.getByText("Team outings")).toBeInTheDocument(); + expect(screen.getByText("Coffee chats")).toBeInTheDocument(); + expect(screen.getByText("Constructive feedback")).toBeInTheDocument(); + expect(screen.getByText("Onboarding")).toBeInTheDocument(); + expect(screen.getByText("Work structure")).toBeInTheDocument(); + expect(screen.getByText("Career growth")).toBeInTheDocument(); + }); + + test("renders the tools autocomplete and a benefits filter body", () => { + render( + + + , + ); + expect(screen.getByTestId("tools-autocomplete")).toBeInTheDocument(); + expect(screen.getAllByTestId("filter-body").length).toBeGreaterThanOrEqual( + 2, + ); + }); + + test("updates the work model when an option is selected", () => { + render(); + fireEvent.click(screen.getByTestId("fb-Work model-INPERSON")); + expect(screen.getByTestId("v-workEnvironment")).toHaveTextContent( + "INPERSON", + ); + }); + + test("parses work hours from the number input and clears to null", () => { + render(); + const input = screen.getByTestId("themed-input"); + fireEvent.change(input, { target: { value: "40" } }); + expect(screen.getByTestId("v-workHours")).toHaveTextContent("40"); + fireEvent.change(input, { target: { value: "" } }); + expect(screen.getByTestId("v-workHours")).toHaveTextContent("null"); + }); + + test("toggles a benefit through the benefits filter body", () => { + render(); + fireEvent.click(screen.getByTestId("fb-Benefits-freeLunch")); + expect(screen.getByTestId("v-freeLunch")).toHaveTextContent("true"); + }); + + test("reflects pre-selected benefits in the selected options", () => { + render( + + + , + ); + // freeLunch default true exercises the selectedBenefits mapping. + expect(screen.getByText("Free lunch")).toBeInTheDocument(); + }); +}); diff --git a/apps/web/test/components/company-info.test.tsx b/apps/web/test/components/company-info.test.tsx new file mode 100644 index 00000000..f0c50e65 --- /dev/null +++ b/apps/web/test/components/company-info.test.tsx @@ -0,0 +1,125 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { beforeEach, describe, expect, test, vi } from "vitest"; + +import type { CompanyType } from "@cooper/db/schema"; + +let companyQuery: { + data?: { id: string; name: string }; + isSuccess: boolean; +}; +let reviewsQuery: { data?: { locationId: string | null }[] }; +// Each location query result keyed by call order. +let locationResults: { data?: { city: string; state: string } }[]; + +vi.mock("~/trpc/react", () => ({ + api: { + company: { + getById: { useQuery: () => companyQuery }, + }, + review: { + getByCompany: { useQuery: () => reviewsQuery }, + }, + useQueries: ( + build: (t: { + location: { + getById: (input: { id: string }) => { id: string }; + }; + }) => unknown[], + ) => { + // Invoke the builder so the proxy code path runs, then return our results. + build({ location: { getById: ({ id }) => ({ id }) } }); + return locationResults; + }, + }, +})); + +vi.mock("@cooper/ui/logo", () => ({ + default: () =>
, +})); + +vi.mock("~/app/_components/shared/favorite-button", () => ({ + FavoriteButton: () => + ), +})); + +vi.mock("~/app/_components/roles/role-info", () => ({ + RoleInfo: ({ roleObj }: { roleObj: { id: string; title?: string } }) => ( +
{roleObj.title ?? roleObj.id}
+ ), +})); + +// trpc api mock - controllable per test +const getByIdQuery = vi.fn(); +const getManyByIdsQuery = vi.fn(); + +vi.mock("~/trpc/react", () => ({ + api: { + role: { + getById: { + useQuery: (...args: unknown[]) => getByIdQuery(...args), + }, + getManyByIds: { + useQuery: (...args: unknown[]) => getManyByIdsQuery(...args), + }, + }, + }, +})); + +import { + CompareColumns, + CompareControls, +} from "~/app/_components/compare/compare-ui"; +import { + CompareProvider, + useCompare, +} from "~/app/_components/compare/compare-context"; + +const anchorRole = { + id: "anchor-1", + title: "Anchor Role", +} as unknown as RoleType; + +// Helper to drive the context from inside the provider during a render. +let driver: ReturnType | null = null; +function Driver() { + driver = useCompare(); + return null; +} + +function renderWithProvider(ui: React.ReactNode) { + return render( + + + {ui} + , + ); +} + +beforeEach(() => { + window.localStorage.clear(); + window.sessionStorage.clear(); + driver = null; + getByIdQuery.mockReturnValue({ data: undefined }); + getManyByIdsQuery.mockReturnValue({ data: [] }); +}); + +// --- CompareControls -------------------------------------------------------- + +describe("CompareControls", () => { + test("renders nothing without an anchor role id", () => { + const { container } = renderWithProvider(); + expect(container.querySelector("button")).toBeNull(); + }); + + test("renders the COMPARE button when not in compare mode", () => { + renderWithProvider(); + expect( + screen.getByRole("button", { name: /COMPARE/i }), + ).toBeInTheDocument(); + }); + + test("entering compare mode swaps to the EXIT COMPARE button", () => { + renderWithProvider(); + fireEvent.click(screen.getByRole("button", { name: /COMPARE/i })); + expect( + screen.getByRole("button", { name: /EXIT COMPARE/i }), + ).toBeInTheDocument(); + expect(driver?.isCompareMode).toBe(true); + expect(driver?.anchorRoleId).toBe("anchor-1"); + }); + + test("clicking EXIT COMPARE exits compare mode", () => { + renderWithProvider(); + fireEvent.click(screen.getByRole("button", { name: /COMPARE/i })); + fireEvent.click(screen.getByRole("button", { name: /EXIT COMPARE/i })); + expect(driver?.isCompareMode).toBe(false); + expect( + screen.getByRole("button", { name: /COMPARE/i }), + ).toBeInTheDocument(); + }); +}); + +// --- CompareColumns --------------------------------------------------------- + +describe("CompareColumns", () => { + test("renders the anchor role plus an empty drop slot when there is capacity", () => { + const { container } = renderWithProvider( + , + ); + expect(screen.getByText("Anchor Role")).toBeInTheDocument(); + // one empty placeholder (dashed border drop slot) present + expect(container.querySelector(".border-dashed")).not.toBeNull(); + }); + + test("renders a loading anchor slot when the anchor role data is unavailable", () => { + getByIdQuery.mockReturnValue({ data: undefined }); + // Provide a different persisted anchor id so it won't match anchorRole.id + window.localStorage.setItem( + "cooper.compare-state", + JSON.stringify({ + comparedRoleIds: [], + reservedSlots: 0, + anchorRoleId: "other-anchor", + }), + ); + renderWithProvider(); + expect(screen.getByText("Loading role...")).toBeInTheDocument(); + }); + + test("renders loaded compared roles fetched by getManyByIds", () => { + getManyByIdsQuery.mockReturnValue({ + data: [{ id: "r1", title: "Role One" }], + }); + window.localStorage.setItem( + "cooper.compare-state", + JSON.stringify({ + comparedRoleIds: ["r1"], + reservedSlots: 0, + anchorRoleId: "anchor-1", + }), + ); + renderWithProvider(); + expect(screen.getByText("Anchor Role")).toBeInTheDocument(); + expect(screen.getByText("Role One")).toBeInTheDocument(); + }); + + test("renders a loading slot for compared roles not yet loaded", () => { + getManyByIdsQuery.mockReturnValue({ data: [] }); + window.localStorage.setItem( + "cooper.compare-state", + JSON.stringify({ + comparedRoleIds: ["r1"], + reservedSlots: 0, + anchorRoleId: "anchor-1", + }), + ); + renderWithProvider(); + expect(screen.getByText("Loading role...")).toBeInTheDocument(); + }); + + test("shows no empty slot once columns reach the max", () => { + getManyByIdsQuery.mockReturnValue({ + data: [ + { id: "r1", title: "Role One" }, + { id: "r2", title: "Role Two" }, + ], + }); + window.localStorage.setItem( + "cooper.compare-state", + JSON.stringify({ + comparedRoleIds: ["r1", "r2"], + reservedSlots: 0, + anchorRoleId: "anchor-1", + }), + ); + renderWithProvider(); + expect(screen.getByText("Anchor Role")).toBeInTheDocument(); + expect(screen.getByText("Role One")).toBeInTheDocument(); + expect(screen.getByText("Role Two")).toBeInTheDocument(); + // no "Drag in..." dragging label because no empty slot when dragging + expect( + screen.queryByText("Drag in or select a card from the list"), + ).toBeNull(); + }); +}); + +// --- DropSlot interactions (rendered via CompareColumns) --------------------- + +describe("DropSlot drag-and-drop", () => { + function getDropSlot(container: HTMLElement) { + const slot = container.querySelector(".border-dashed"); + if (!slot) throw new Error("drop slot not found"); + return slot as HTMLElement; + } + + function makeDataTransfer(map: Record): DataTransfer { + return { + getData: (type: string) => map[type] ?? "", + } as unknown as DataTransfer; + } + + test("ignores drag interactions when not in compare mode", () => { + const { container } = renderWithProvider( + , + ); + const slot = getDropSlot(container); + // not in compare mode: dragOver should be a no-op (no active styling) + fireEvent.dragOver(slot); + expect(slot.className).not.toContain("border-cooper-blue-400"); + }); + + test("activates on drag over and deactivates on drag leave in compare mode", () => { + const { container } = renderWithProvider( + , + ); + act(() => driver?.enterCompareMode("anchor-1")); + const slot = getDropSlot(container); + fireEvent.dragOver(slot); + expect(slot.className).toContain("border-cooper-blue-400"); + fireEvent.dragLeave(slot); + expect(slot.className).not.toContain("border-cooper-blue-400"); + }); + + test("drop adds a new role id from application/role-id", () => { + const { container } = renderWithProvider( + , + ); + act(() => driver?.enterCompareMode("anchor-1")); + const slot = getDropSlot(container); + fireEvent.drop(slot, { + dataTransfer: makeDataTransfer({ "application/role-id": "new-role" }), + }); + expect(driver?.comparedRoleIds).toContain("new-role"); + }); + + test("drop falls back to text/plain data", () => { + const { container } = renderWithProvider( + , + ); + act(() => driver?.enterCompareMode("anchor-1")); + const slot = getDropSlot(container); + fireEvent.drop(slot, { + dataTransfer: makeDataTransfer({ "text/plain": "plain-role" }), + }); + expect(driver?.comparedRoleIds).toContain("plain-role"); + }); + + test("drop ignores the anchor role id", () => { + const { container } = renderWithProvider( + , + ); + act(() => driver?.enterCompareMode("anchor-1")); + const slot = getDropSlot(container); + fireEvent.drop(slot, { + dataTransfer: makeDataTransfer({ "application/role-id": "anchor-1" }), + }); + expect(driver?.comparedRoleIds).not.toContain("anchor-1"); + }); + + test("drop ignores empty payloads", () => { + const { container } = renderWithProvider( + , + ); + act(() => driver?.enterCompareMode("anchor-1")); + const before = [...(driver?.comparedRoleIds ?? [])]; + const slot = getDropSlot(container); + fireEvent.drop(slot, { dataTransfer: makeDataTransfer({}) }); + expect(driver?.comparedRoleIds).toEqual(before); + }); + + test("drop is a no-op when not in compare mode", () => { + const { container } = renderWithProvider( + , + ); + const slot = getDropSlot(container); + fireEvent.drop(slot, { + dataTransfer: makeDataTransfer({ "application/role-id": "x" }), + }); + expect(driver?.comparedRoleIds ?? []).not.toContain("x"); + }); + + test("window dragstart on a draggable element sets dragging while in compare mode", () => { + const { container } = renderWithProvider( + , + ); + act(() => driver?.enterCompareMode("anchor-1")); + + const draggable = document.createElement("div"); + draggable.setAttribute("draggable", "true"); + document.body.appendChild(draggable); + + act(() => { + const evt = new Event("dragstart", { bubbles: true }); + Object.defineProperty(evt, "target", { value: draggable }); + window.dispatchEvent(evt); + }); + expect(driver?.isDragging).toBe(true); + + // dragging label should now appear in the empty slot + expect( + screen.getByText("Drag in or select a card from the list"), + ).toBeInTheDocument(); + + act(() => { + window.dispatchEvent(new Event("dragend")); + }); + expect(driver?.isDragging).toBe(false); + + document.body.removeChild(draggable); + // touch container to satisfy lint (unused var guard) + expect(container).toBeTruthy(); + }); + + test("window dragstart ignores non-draggable targets", () => { + renderWithProvider(); + act(() => driver?.enterCompareMode("anchor-1")); + + const plain = document.createElement("span"); + document.body.appendChild(plain); + act(() => { + const evt = new Event("dragstart", { bubbles: true }); + Object.defineProperty(evt, "target", { value: plain }); + window.dispatchEvent(evt); + }); + expect(driver?.isDragging).toBe(false); + document.body.removeChild(plain); + }); +}); diff --git a/apps/web/test/components/create-user-form.test.tsx b/apps/web/test/components/create-user-form.test.tsx new file mode 100644 index 00000000..c3c691cf --- /dev/null +++ b/apps/web/test/components/create-user-form.test.tsx @@ -0,0 +1,95 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import type { ChangeEvent } from "react"; +import { beforeEach, describe, expect, test, vi } from "vitest"; + +const mutate = vi.fn(); +let mutationHandlers: { + onSuccess?: () => void; + onError?: (e: { message: string }) => void; +} = {}; + +vi.mock("~/trpc/react", () => ({ + api: { + user: { + create: { + useMutation: (handlers: typeof mutationHandlers) => { + mutationHandlers = handlers; + return { mutate }; + }, + }, + }, + }, +})); + +const success = vi.fn(); +const error = vi.fn(); +vi.mock("@cooper/ui", () => ({ + cn: (...inputs: unknown[]) => inputs.flat().filter(Boolean).join(" "), + useCustomToast: () => ({ toast: { success, error } }), +})); + +// Isolate the form from the themed Select implementation. +vi.mock("~/app/_components/themed/onboarding/select", () => ({ + Select: ({ + options, + value, + onChange, + placeholder, + }: { + options: { value: string; label: string }[]; + value: string; + onChange: (e: ChangeEvent) => void; + placeholder: string; + }) => ( + + ), +})); + +import { CreateUserForm } from "~/app/_components/admin/create-user-form"; + +beforeEach(() => { + vi.clearAllMocks(); + mutationHandlers = {}; +}); + +describe("CreateUserForm", () => { + test("does not submit when email or role is missing", () => { + render(); + fireEvent.click(screen.getByRole("button", { name: "Submit" })); + expect(mutate).not.toHaveBeenCalled(); + }); + + test("submits the email and role", () => { + render(); + fireEvent.change(screen.getByPlaceholderText("Add new user email here"), { + target: { value: "new@husky.neu.edu" }, + }); + fireEvent.change(screen.getByLabelText("role"), { + target: { value: "ADMIN" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Submit" })); + expect(mutate).toHaveBeenCalledWith({ + email: "new@husky.neu.edu", + role: "ADMIN", + }); + }); + + test("shows a success toast and clears the form on success", () => { + render(); + mutationHandlers.onSuccess?.(); + expect(success).toHaveBeenCalledWith("User added successfully."); + }); + + test("shows an error toast on failure", () => { + render(); + mutationHandlers.onError?.({ message: "boom" }); + expect(error).toHaveBeenCalledWith("boom"); + }); +}); diff --git a/apps/web/test/components/dashboard-table.test.tsx b/apps/web/test/components/dashboard-table.test.tsx new file mode 100644 index 00000000..6d1c49f4 --- /dev/null +++ b/apps/web/test/components/dashboard-table.test.tsx @@ -0,0 +1,351 @@ +import { fireEvent, render, screen, within } from "@testing-library/react"; +import { beforeEach, describe, expect, test, vi } from "vitest"; + +const setFlaggedMutate = vi.fn(); +const setHiddenMutate = vi.fn(); +const invalidate = vi.fn(); + +interface QueryResult { + data?: { items: unknown[] }; + isLoading: boolean; +} + +let recentResult: QueryResult; +let flaggedResult: QueryResult; +let hiddenResult: QueryResult; +let reportedResult: QueryResult; + +vi.mock("~/trpc/react", () => ({ + api: { + useUtils: () => ({ + admin: { + dashboardItems: { invalidate }, + flaggedDashboardItems: { invalidate }, + hiddenDashboardItems: { invalidate }, + reportedDashboardItems: { invalidate }, + }, + }), + admin: { + dashboardItems: { useQuery: () => recentResult }, + flaggedDashboardItems: { useQuery: () => flaggedResult }, + hiddenDashboardItems: { useQuery: () => hiddenResult }, + reportedDashboardItems: { useQuery: () => reportedResult }, + setFlaggedStatus: { + useMutation: () => ({ mutate: setFlaggedMutate, isPending: false }), + }, + setHiddenStatus: { + useMutation: () => ({ mutate: setHiddenMutate, isPending: false }), + }, + }, + }, +})); + +import { AdminDashboardTable } from "~/app/_components/admin/dashboard-table"; + +const review = (overrides: Record = {}) => ({ + type: "review", + id: "rev-1", + text: "Great internship experience", + headline: "Loved it", + createdAt: new Date("2024-01-01").toISOString(), + flagged: false, + hidden: false, + ...overrides, +}); + +const role = (overrides: Record = {}) => ({ + type: "role", + id: "role-1", + title: "Software Engineer Intern", + companyId: "Acme Corp", + createdAt: new Date("2024-02-01").toISOString(), + flagged: false, + hidden: false, + ...overrides, +}); + +const company = (overrides: Record = {}) => ({ + type: "company", + id: "co-1", + name: "Globex", + createdAt: new Date("2024-03-01").toISOString(), + flagged: false, + hidden: false, + ...overrides, +}); + +beforeEach(() => { + vi.clearAllMocks(); + recentResult = { data: { items: [] }, isLoading: false }; + flaggedResult = { data: { items: [] }, isLoading: false }; + hiddenResult = { data: { items: [] }, isLoading: false }; + reportedResult = { data: { items: [] }, isLoading: false }; +}); + +/** Returns the "New this week" section so queries are scoped to one table. */ +const recentSection = () => + screen.getByRole("button", { name: /New this week/ }).closest("div")!; + +describe("AdminDashboardTable", () => { + test("shows a loading row while the recent section loads", () => { + recentResult = { data: undefined, isLoading: true }; + render(); + expect(screen.getByText("Loading dashboard data…")).toBeInTheDocument(); + }); + + test("renders the four section headers", () => { + render(); + expect( + screen.getByRole("button", { name: /New this week/ }), + ).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: /Reported/ }), + ).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /Flagged/ })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /Hidden/ })).toBeInTheDocument(); + }); + + test("renders items of every category with the right labels", () => { + recentResult = { + data: { items: [review(), role(), company()] }, + isLoading: false, + }; + render(); + + expect(screen.getByText("Great internship experience")).toBeInTheDocument(); + expect(screen.getByText("Software Engineer Intern")).toBeInTheDocument(); + expect(screen.getByText("Globex")).toBeInTheDocument(); + // Company id appears alongside the role title. + expect(screen.getByText(/Acme Corp/)).toBeInTheDocument(); + }); + + test("shows an empty state for sections without items", () => { + render(); + expect( + screen.getAllByText("No results in this section.").length, + ).toBeGreaterThan(0); + }); + + test("ignores malformed raw items", () => { + recentResult = { + data: { + items: [ + review(), + { type: "unknown", id: "x" }, + { type: "role", id: "no-title" }, // role missing title -> dropped + null, + ], + }, + isLoading: false, + }; + render(); + expect(screen.getByText("Great internship experience")).toBeInTheDocument(); + expect(within(recentSection()).getAllByRole("row").length).toBeGreaterThan( + 0, + ); + }); + + test("filters items by the active category tab", () => { + recentResult = { + data: { items: [review(), role(), company()] }, + isLoading: false, + }; + render(); + + fireEvent.click(screen.getByRole("button", { name: "Role" })); + + expect(screen.getByText("Software Engineer Intern")).toBeInTheDocument(); + expect(screen.queryByText("Great internship experience")).toBeNull(); + expect(screen.queryByText("Globex")).toBeNull(); + }); + + test("updates the search input value", () => { + render(); + const input = screen.getByPlaceholderText( + "Search for a review, role, company...", + ); + fireEvent.change(input, { target: { value: "acme" } }); + expect(input).toHaveValue("acme"); + }); + + test("collapses a section when its header is toggled", () => { + render(); + const header = screen.getByRole("button", { name: /New this week/ }); + expect(header).toHaveAttribute("aria-expanded", "true"); + fireEvent.click(header); + expect(header).toHaveAttribute("aria-expanded", "false"); + }); + + describe("flagging", () => { + test("opens the flag dialog and requires a reason before submitting", () => { + recentResult = { + data: { items: [review({ flagged: false })] }, + isLoading: false, + }; + render(); + + fireEvent.click(screen.getByRole("button", { name: "Flag item" })); + + expect( + screen.getByRole("heading", { name: "Flag this item?" }), + ).toBeInTheDocument(); + + const submit = screen.getByRole("button", { name: "Flag" }); + expect(submit).toBeDisabled(); + + fireEvent.change(screen.getByPlaceholderText("Enter a reason..."), { + target: { value: "Spam content" }, + }); + expect(submit).toBeEnabled(); + + fireEvent.click(submit); + expect(setFlaggedMutate).toHaveBeenCalledWith({ + entityType: "review", + entityId: "rev-1", + flagged: true, + description: "Spam content", + }); + }); + + test("unflags immediately without opening a dialog", () => { + recentResult = { + data: { items: [review({ flagged: true })] }, + isLoading: false, + }; + render(); + + fireEvent.click(screen.getByRole("button", { name: "Remove flag" })); + + expect( + screen.queryByRole("heading", { name: "Flag this item?" }), + ).toBeNull(); + expect(setFlaggedMutate).toHaveBeenCalledWith({ + entityType: "review", + entityId: "rev-1", + flagged: false, + }); + }); + }); + + describe("hiding", () => { + test("opens the hide dialog and confirms hiding", () => { + recentResult = { + data: { items: [review({ hidden: false })] }, + isLoading: false, + }; + render(); + + fireEvent.click(screen.getByRole("button", { name: "Hide content" })); + + expect( + screen.getByRole("heading", { name: "Hide this item?" }), + ).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: "Hide" })); + expect(setHiddenMutate).toHaveBeenCalledWith({ + entityType: "review", + entityId: "rev-1", + hidden: true, + }); + }); + + test("unhides immediately without opening a dialog", () => { + recentResult = { + data: { items: [review({ hidden: true })] }, + isLoading: false, + }; + render(); + + fireEvent.click(screen.getByRole("button", { name: "Show content" })); + + expect( + screen.queryByRole("heading", { name: "Hide this item?" }), + ).toBeNull(); + expect(setHiddenMutate).toHaveBeenCalledWith({ + entityType: "review", + entityId: "rev-1", + hidden: false, + }); + }); + + test("cancelling the hide dialog does not call the mutation", () => { + recentResult = { + data: { items: [review({ hidden: false })] }, + isLoading: false, + }; + render(); + + fireEvent.click(screen.getByRole("button", { name: "Hide content" })); + fireEvent.click(screen.getByRole("button", { name: "Cancel" })); + + expect(setHiddenMutate).not.toHaveBeenCalled(); + }); + }); + + describe("section-specific data", () => { + test("flagged section only shows flagged items", () => { + flaggedResult = { + data: { + items: [ + review({ id: "rev-9", text: "Flagged review", flagged: true }), + ], + }, + isLoading: false, + }; + render(); + expect(screen.getByText("Flagged review")).toBeInTheDocument(); + }); + + test("hidden section only shows hidden items", () => { + hiddenResult = { + data: { + items: [review({ id: "rev-8", text: "Hidden review", hidden: true })], + }, + isLoading: false, + }; + render(); + expect(screen.getByText("Hidden review")).toBeInTheDocument(); + }); + + test("flagged section drops non-flagged items", () => { + flaggedResult = { + data: { + items: [review({ id: "rev-7", text: "Not flagged", flagged: false })], + }, + isLoading: false, + }; + render(); + expect(screen.queryByText("Not flagged")).toBeNull(); + }); + }); + + describe("pagination", () => { + const manyReviews = Array.from({ length: 7 }, (_, i) => + review({ id: `rev-${i}`, text: `Review number ${i}` }), + ); + + test("paginates items beyond the page size", () => { + recentResult = { data: { items: manyReviews }, isLoading: false }; + render(); + + const section = recentSection(); + // Page 1 shows the first five reviews, not the sixth. + expect(within(section).getByText("Review number 0")).toBeInTheDocument(); + expect(within(section).queryByText("Review number 5")).toBeNull(); + + fireEvent.click(within(section).getByRole("button", { name: "2" })); + + expect(within(section).getByText("Review number 5")).toBeInTheDocument(); + expect(within(section).queryByText("Review number 0")).toBeNull(); + }); + + test("disables the previous-page control on the first page", () => { + recentResult = { data: { items: manyReviews }, isLoading: false }; + render(); + const section = recentSection(); + expect( + within(section).getByRole("button", { name: "Previous page" }), + ).toBeDisabled(); + }); + }); +}); diff --git a/apps/web/test/components/delete-review-dialogue.test.tsx b/apps/web/test/components/delete-review-dialogue.test.tsx new file mode 100644 index 00000000..8ad5a569 --- /dev/null +++ b/apps/web/test/components/delete-review-dialogue.test.tsx @@ -0,0 +1,63 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { beforeEach, describe, expect, test, vi } from "vitest"; + +const h = vi.hoisted(() => ({ + mutate: vi.fn(), + success: vi.fn(), + error: vi.fn(), +})); + +vi.mock("@cooper/ui/hooks/use-custom-toast", () => ({ + useCustomToast: () => ({ toast: { success: h.success, error: h.error } }), +})); +vi.mock("~/trpc/react", () => ({ + api: { + review: { delete: { useMutation: () => ({ mutate: h.mutate }) } }, + }, +})); + +import { DeleteReviewDialog } from "~/app/_components/reviews/delete-review-dialogue"; + +describe("DeleteReviewDialog", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + test("renders the default trash trigger", () => { + render(); + expect(screen.getByRole("button")).toBeInTheDocument(); + }); + + test("opens the confirmation dialog for a review", () => { + render(); + fireEvent.click(screen.getByRole("button")); + expect( + screen.getByRole("heading", { name: "Delete Review" }), + ).toBeInTheDocument(); + expect( + screen.getByText(/Are you sure you want to delete this review/), + ).toBeInTheDocument(); + }); + + test("uses draft wording when isDraft is set", () => { + render(); + fireEvent.click(screen.getByRole("button")); + expect( + screen.getByRole("heading", { name: "Delete Draft" }), + ).toBeInTheDocument(); + expect( + screen.getByText(/Are you sure you want to delete this draft/), + ).toBeInTheDocument(); + }); + + test("calls the delete mutation with the review id on confirm", () => { + render(); + fireEvent.click(screen.getByRole("button")); + // The confirm button shares the "Delete Review" label inside the dialog. + const confirm = screen + .getAllByText("Delete Review") + .find((el) => el.tagName === "BUTTON"); + fireEvent.click(confirm!); + expect(h.mutate).toHaveBeenCalledWith("rev-1"); + }); +}); diff --git a/apps/web/test/components/draft-review-card.test.tsx b/apps/web/test/components/draft-review-card.test.tsx new file mode 100644 index 00000000..4b6947cd --- /dev/null +++ b/apps/web/test/components/draft-review-card.test.tsx @@ -0,0 +1,69 @@ +import { render, screen } from "@testing-library/react"; +import { beforeEach, describe, expect, test, vi } from "vitest"; + +import type { ReviewType } from "@cooper/db/schema"; + +const state: { + role?: { title: string }; + company?: { name: string }; + location?: { city: string; state: string; country: string }; +} = {}; + +vi.mock("~/trpc/react", () => ({ + api: { + role: { getById: { useQuery: () => ({ data: state.role }) } }, + company: { getById: { useQuery: () => ({ data: state.company }) } }, + location: { getById: { useQuery: () => ({ data: state.location }) } }, + }, +})); +vi.mock("~/app/_components/reviews/review-actions-dialogue", () => ({ + ReviewActionsDialog: () =>
, +})); +vi.mock("~/utils/dateHelpers", () => ({ + formatLastEditedDate: () => "Edited just now", +})); + +import { DraftReviewCard } from "~/app/_components/reviews/draft-review-card"; + +const draft = { + id: "rev-1", + overallRating: 3, + roleId: "role-1", + companyId: "company-1", + locationId: "loc-1", + updatedAt: new Date("2024-03-15T00:00:00Z"), + createdAt: new Date("2024-03-10T00:00:00Z"), +} as unknown as ReviewType; + +describe("DraftReviewCard", () => { + beforeEach(() => { + state.role = { title: "Software Engineer" }; + state.company = { name: "Acme" }; + state.location = { city: "Boston", state: "MA", country: "USA" }; + }); + + test("renders the role title, company, and Draft badge", () => { + render(); + expect(screen.getByText("Software Engineer")).toBeInTheDocument(); + expect(screen.getByText("Acme")).toBeInTheDocument(); + expect(screen.getByText("Draft")).toBeInTheDocument(); + }); + + test("shows placeholder text when role and company are missing", () => { + state.role = undefined; + state.company = undefined; + render(); + expect(screen.getByText("Job title")).toBeInTheDocument(); + expect(screen.getByText("Company name")).toBeInTheDocument(); + }); + + test("renders the formatted last-edited date", () => { + render(); + expect(screen.getByText("Edited just now")).toBeInTheDocument(); + }); + + test("shows 0.0 rating placeholder when there is no rating", () => { + render(); + expect(screen.getByText("0.0")).toBeInTheDocument(); + }); +}); diff --git a/apps/web/test/components/dropdown-filter.test.tsx b/apps/web/test/components/dropdown-filter.test.tsx new file mode 100644 index 00000000..b152e0e8 --- /dev/null +++ b/apps/web/test/components/dropdown-filter.test.tsx @@ -0,0 +1,312 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, test, vi } from "vitest"; + +vi.mock("@cooper/ui/autocomplete", () => ({ + default: ({ placeholder }: { placeholder?: string }) => ( + + ), +})); + +import DropdownFilter, { + FilterPanelContent, +} from "~/app/_components/filters/dropdown-filter"; + +const options = [ + { id: "a", label: "Apple", value: "apple" }, + { id: "b", label: "Banana", value: "banana" }, +]; + +describe("DropdownFilter - trigger / display text", () => { + test("shows the title when nothing selected (checkbox)", () => { + render( + , + ); + expect(screen.getByText("Job type")).toBeInTheDocument(); + }); + + test("shows first selected label for checkbox", () => { + render( + , + ); + expect(screen.getByText("Apple")).toBeInTheDocument(); + }); + + test("shows +N when multiple selected", () => { + render( + , + ); + expect(screen.getByText("Apple +1")).toBeInTheDocument(); + }); + + test("falls back to the raw id when label not found", () => { + render( + , + ); + expect(screen.getByText("missing")).toBeInTheDocument(); + }); + + test("range display: title when no range", () => { + render( + , + ); + expect(screen.getByText("Hourly pay")).toBeInTheDocument(); + }); + + test("range display: both min and max", () => { + render( + , + ); + expect(screen.getByText("$10-20/hr")).toBeInTheDocument(); + }); + + test("range display: min only", () => { + render( + , + ); + expect(screen.getByText("$10/hr+")).toBeInTheDocument(); + }); + + test("range display: max only", () => { + render( + , + ); + expect(screen.getByText("Up to $20/hr")).toBeInTheDocument(); + }); + + test("range display: Infinity max treated as no max", () => { + render( + , + ); + expect(screen.getByText("$10/hr+")).toBeInTheDocument(); + }); + + test("rating display: title when none selected", () => { + render( + , + ); + expect(screen.getByText("Overall rating")).toBeInTheDocument(); + }); + + test("rating display: single rating", () => { + render( + , + ); + expect(screen.getByText("3.0+ stars")).toBeInTheDocument(); + }); + + test("rating display: range of ratings shows star image", () => { + render( + , + ); + expect(screen.getByAltText("Star icon")).toBeInTheDocument(); + }); +}); + +describe("DropdownFilter - triggerOnly mode", () => { + test("renders only the trigger and fires onTriggerClick", () => { + const onTriggerClick = vi.fn(); + render( + , + ); + fireEvent.click(screen.getByText("Industry")); + expect(onTriggerClick).toHaveBeenCalledOnce(); + }); +}); + +describe("DropdownFilter - dropdown content (uncontrolled)", () => { + test("opens on trigger click and shows the title and Clear in the panel", () => { + render( + , + ); + fireEvent.pointerDown(screen.getByText("Apple")); + // panel header Clear button appears + expect(screen.getByText("Clear")).toBeInTheDocument(); + }); + + test("Clear in panel calls onSelectionChange with empty array", () => { + const onSelectionChange = vi.fn(); + render( + , + ); + fireEvent.pointerDown(screen.getByText("Apple")); + fireEvent.click(screen.getByText("Clear")); + expect(onSelectionChange).toHaveBeenCalledWith([]); + }); + + test("Clear for range also resets the range", () => { + const onSelectionChange = vi.fn(); + const onRangeChange = vi.fn(); + render( + , + ); + fireEvent.pointerDown(screen.getByText("$10-20/hr")); + fireEvent.click(screen.getByText("Clear")); + expect(onRangeChange).toHaveBeenCalledWith(0, 0); + }); +}); + +describe("DropdownFilter - controlled open", () => { + test("uses controlled open value and calls onOpenChange", () => { + const onOpenChange = vi.fn(); + render( + , + ); + // Open: panel content visible + expect(screen.getByText("Clear")).toBeInTheDocument(); + // The X close button calls setIsOpen(false) -> onOpenChange(false) + const buttons = screen.getAllByRole("button"); + // click the close (X) button - it's the last button in the header area + fireEvent.click(buttons[buttons.length - 1]!); + expect(onOpenChange).toHaveBeenCalledWith(false); + }); +}); + +describe("FilterPanelContent", () => { + test("renders title, body, and Clear", () => { + const onSelectionChange = vi.fn(); + render( + , + ); + expect(screen.getByText("Job type")).toBeInTheDocument(); + fireEvent.click(screen.getByText("Clear")); + expect(onSelectionChange).toHaveBeenCalledWith([]); + }); + + test("Clear resets range for range filter type", () => { + const onRangeChange = vi.fn(); + render( + , + ); + fireEvent.click(screen.getByText("Clear")); + expect(onRangeChange).toHaveBeenCalledWith(0, 0); + }); + + test("close button calls onClose", () => { + const onClose = vi.fn(); + render( + , + ); + const buttons = screen.getAllByRole("button"); + fireEvent.click(buttons[buttons.length - 1]!); + expect(onClose).toHaveBeenCalledOnce(); + }); +}); diff --git a/apps/web/test/components/dropdown-filters-bar.test.tsx b/apps/web/test/components/dropdown-filters-bar.test.tsx new file mode 100644 index 00000000..7f94574d --- /dev/null +++ b/apps/web/test/components/dropdown-filters-bar.test.tsx @@ -0,0 +1,241 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { beforeEach, describe, expect, test, vi } from "vitest"; + +import type { FilterState } from "~/app/_components/filters/types"; + +// Mutable result for the popularity query plus a record of how useQueries was +// invoked, so tests can drive both location code paths. +const h = vi.hoisted(() => { + const locationQuery: { + data: { id: string; label: string }[] | undefined; + } = { data: undefined }; + const selectedLocationData: unknown[] = []; + return { locationQuery, selectedLocationData }; +}); + +vi.mock("~/trpc/react", () => ({ + api: { + location: { + getByPopularity: { useQuery: () => h.locationQuery }, + }, + // The component builds queries for each selected location id; invoke the + // builder so its code path runs, then return our canned results. + useQueries: ( + build: (t: { + location: { getById: (input: { id: string }) => unknown }; + }) => unknown[], + ) => { + build({ + location: { getById: ({ id }) => ({ data: { id } }) }, + }); + return h.selectedLocationData.map((data) => ({ data })); + }, + }, +})); + +vi.mock("~/utils/locationHelpers", () => ({ + prettyLocationName: (loc: { id: string; label?: string }) => + loc.label ?? loc.id, +})); + +vi.mock("@cooper/ui/popover", () => ({ + Popover: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + PopoverAnchor: ({ children }: { children: React.ReactNode }) => ( + <>{children} + ), + PopoverContent: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), +})); + +// DropdownFilter renders the trigger; FilterPanelContent renders the open +// panel. Both are mocked to surface their props as buttons / text. +vi.mock("~/app/_components/filters/dropdown-filter", () => ({ + default: ({ + title, + onTriggerClick, + options, + }: { + title: string; + onTriggerClick?: () => void; + options: { id: string; label: string }[]; + }) => ( + + ), + FilterPanelContent: ({ + title, + options, + onSelectionChange, + onSearchChange, + onClose, + }: { + title: string; + options: { id: string; label: string }[]; + onSelectionChange?: (s: string[]) => void; + onSearchChange?: (s: string) => void; + onClose: () => void; + }) => ( +
+ + {options.map((o) => o.label).join(",")} + + + {onSearchChange && ( + + )} + +
+ ), +})); + +import DropdownFiltersBar from "~/app/_components/filters/dropdown-filters-bar"; + +const emptyFilters: FilterState = { + industries: [], + locations: [], + jobTypes: [], + hourlyPay: { min: 0, max: 0 }, + ratings: [], + workModels: [], + overtimeWork: [], + companyCulture: [], +}; + +function renderBar(filters: FilterState = emptyFilters) { + const onFilterChange = vi.fn(); + render( + , + ); + return { onFilterChange }; +} + +beforeEach(() => { + h.locationQuery = { data: undefined }; + h.selectedLocationData = []; +}); + +describe("DropdownFiltersBar - triggers", () => { + test("renders every filter trigger", () => { + renderBar(); + expect(screen.getByTestId("trigger-Industry")).toBeInTheDocument(); + expect(screen.getByTestId("trigger-Location")).toBeInTheDocument(); + expect(screen.getByTestId("trigger-Job type")).toBeInTheDocument(); + expect(screen.getByTestId("trigger-Hourly pay")).toBeInTheDocument(); + expect(screen.getByTestId("trigger-Overall rating")).toBeInTheDocument(); + }); + + test("no panel is shown before any trigger is clicked", () => { + renderBar(); + expect(screen.queryByTestId("panel-Industry")).toBeNull(); + }); + + test("industry options are sorted alphabetically", () => { + renderBar(); + const labels = + screen.getByTestId("trigger-opts-Industry").textContent ?? ""; + expect(labels.startsWith("Aerospace")).toBe(true); + }); +}); + +describe("DropdownFiltersBar - open / close behavior", () => { + test("clicking a trigger opens the matching panel", () => { + renderBar(); + fireEvent.click(screen.getByTestId("trigger-Industry")); + expect(screen.getByTestId("panel-Industry")).toBeInTheDocument(); + // Only the clicked filter's panel renders. + expect(screen.queryByTestId("panel-Location")).toBeNull(); + }); + + test("clicking the same trigger twice toggles the panel closed", () => { + renderBar(); + fireEvent.click(screen.getByTestId("trigger-Job type")); + expect(screen.getByTestId("panel-Job type")).toBeInTheDocument(); + fireEvent.click(screen.getByTestId("trigger-Job type")); + expect(screen.queryByTestId("panel-Job type")).toBeNull(); + }); + + test("opening a second filter swaps the visible panel", () => { + renderBar(); + fireEvent.click(screen.getByTestId("trigger-Industry")); + fireEvent.click(screen.getByTestId("trigger-Location")); + expect(screen.queryByTestId("panel-Industry")).toBeNull(); + expect(screen.getByTestId("panel-Location")).toBeInTheDocument(); + }); + + test("panel close button closes the panel", () => { + renderBar(); + fireEvent.click(screen.getByTestId("trigger-Overall rating")); + fireEvent.click(screen.getByTestId("panel-close-Overall rating")); + expect(screen.queryByTestId("panel-Overall rating")).toBeNull(); + }); +}); + +describe("DropdownFiltersBar - filter changes", () => { + test("a panel selection merges into existing filters", () => { + const { onFilterChange } = renderBar(); + fireEvent.click(screen.getByTestId("trigger-Industry")); + fireEvent.click(screen.getByTestId("panel-select-Industry")); + expect(onFilterChange).toHaveBeenCalledWith({ + ...emptyFilters, + industries: ["picked"], + }); + }); + + test("rating panel selection updates ratings", () => { + const { onFilterChange } = renderBar(); + fireEvent.click(screen.getByTestId("trigger-Overall rating")); + fireEvent.click(screen.getByTestId("panel-select-Overall rating")); + expect(onFilterChange).toHaveBeenCalledWith({ + ...emptyFilters, + ratings: ["picked"], + }); + }); +}); + +describe("DropdownFiltersBar - location options", () => { + test("merges prefix results into location options", () => { + h.locationQuery = { data: [{ id: "1", label: "Boston, MA" }] }; + renderBar(); + fireEvent.click(screen.getByTestId("trigger-Location")); + fireEvent.click(screen.getByTestId("panel-search-Location")); + expect(screen.getByTestId("panel-opts-Location").textContent).toContain( + "Boston, MA", + ); + }); + + test("includes selected-location labels and dedupes by id", () => { + // Same id present in both the selected results and the prefix results. + h.selectedLocationData = [{ id: "1", label: "Boston, MA" }]; + h.locationQuery = { + data: [ + { id: "1", label: "Boston, MA" }, + { id: "2", label: "Austin, TX" }, + ], + }; + renderBar({ ...emptyFilters, locations: ["1"] }); + fireEvent.click(screen.getByTestId("trigger-Location")); + const labels = screen.getByTestId("panel-opts-Location").textContent ?? ""; + // Boston appears once despite being in both sources. + expect(labels.match(/Boston, MA/g)?.length).toBe(1); + expect(labels).toContain("Austin, TX"); + }); +}); diff --git a/apps/web/test/components/existing-company-content.test.tsx b/apps/web/test/components/existing-company-content.test.tsx new file mode 100644 index 00000000..417ad278 --- /dev/null +++ b/apps/web/test/components/existing-company-content.test.tsx @@ -0,0 +1,304 @@ +import type { ReactNode } from "react"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { FormProvider, useForm } from "react-hook-form"; +import { beforeEach, describe, expect, test, vi } from "vitest"; + +const h = vi.hoisted(() => ({ + state: { + companies: [ + { id: "c1", name: "Acme", industry: "Technology" }, + { id: "c2", name: "Beta", industry: "Finance" }, + ] as { id: string; name: string; industry: string }[], + roles: [] as { id: string; title: string }[], + locations: [] as unknown[], + }, + createCompanySpy: vi + .fn() + .mockResolvedValue({ roleId: "r1", companyId: "c1" }), + createRoleSpy: vi.fn().mockResolvedValue([{ id: "newrole" }]), + toastError: vi.fn(), + toastSuccess: vi.fn(), +})); + +// A light stand-in for FilterBody that exposes its callbacks as buttons so we +// can drive selection/search without the real autocomplete UI. +vi.mock("~/app/_components/filters/filter-body", () => ({ + default: ({ + title, + options, + placeholder, + onSelectionChange, + onSearchChange, + }: { + title: string; + options: { value: string }[]; + placeholder?: string; + onSelectionChange?: (selected: (string | undefined)[]) => void; + onSearchChange?: (term: string) => void; + }) => ( +
+ {placeholder} + {options.length} + + + onSearchChange?.(e.target.value)} + /> +
+ ), +})); + +vi.mock("~/app/_components/location", () => ({ + default: () =>
, +})); +vi.mock("~/app/_components/companies/company-card-preview", () => ({ + CompanyCardPreview: ({ companyObj }: { companyObj: { name: string } }) => ( +
{companyObj.name}
+ ), +})); + +vi.mock("@cooper/ui/hooks/use-custom-toast", () => ({ + useCustomToast: () => ({ + toast: { success: h.toastSuccess, error: h.toastError }, + }), +})); + +vi.mock("~/trpc/react", () => ({ + api: { + company: { + list: { useQuery: () => ({ data: h.state.companies, refetch: vi.fn() }) }, + createWithRole: { + useMutation: () => ({ + mutateAsync: h.createCompanySpy, + isPending: false, + }), + }, + }, + role: { + getByCompany: { + useQuery: () => ({ data: h.state.roles, refetch: vi.fn() }), + }, + create: { + useMutation: () => ({ mutateAsync: h.createRoleSpy, isPending: false }), + }, + }, + location: { + getByPopularity: { useQuery: () => ({ data: h.state.locations }) }, + }, + }, +})); + +import ExistingCompanyContent from "~/app/_components/reviews/existing-company-content"; + +function Wrapper({ + children, + defaults = {}, +}: { + children: ReactNode; + defaults?: Record; +}) { + const form = useForm({ + defaultValues: { + companyName: "", + roleName: "", + industry: "", + locationId: "", + title: "", + ...defaults, + }, + }); + return {children}; +} + +function renderContent(defaults?: Record) { + return render( + + + , + ); +} + +describe("ExistingCompanyContent", () => { + beforeEach(() => { + vi.clearAllMocks(); + h.state.companies = [ + { id: "c1", name: "Acme", industry: "Technology" }, + { id: "c2", name: "Beta", industry: "Finance" }, + ]; + h.state.roles = []; + }); + + test("renders the company and role pickers", () => { + renderContent(); + expect(screen.getByText("Company name")).toBeInTheDocument(); + expect(screen.getByText("I don't see my company")).toBeInTheDocument(); + expect(screen.getByText("I don't see my role")).toBeInTheDocument(); + // Both company options are passed through to FilterBody. + expect(screen.getByTestId("count-Company")).toHaveTextContent("2"); + }); + + test("selecting a company shows its preview card", async () => { + renderContent(); + fireEvent.click(screen.getByTestId("select-Company")); + expect(await screen.findByTestId("company-card")).toHaveTextContent("Acme"); + }); + + test("clearing the company selection removes the preview", async () => { + renderContent(); + fireEvent.click(screen.getByTestId("select-Company")); + expect(await screen.findByTestId("company-card")).toBeInTheDocument(); + fireEvent.click(screen.getByTestId("clear-Company")); + await waitFor(() => + expect(screen.queryByTestId("company-card")).not.toBeInTheDocument(), + ); + }); + + test("company search filters the option list", () => { + renderContent(); + fireEvent.change(screen.getByTestId("search-Company"), { + target: { value: "bet" }, + }); + expect(screen.getByTestId("count-Company")).toHaveTextContent("1"); + }); + + test("'I don't see my company' reveals the add-company form", () => { + renderContent(); + fireEvent.click(screen.getByText("I don't see my company")); + expect(screen.getByText("Add Your Company")).toBeInTheDocument(); + expect(screen.getByText("Create Company & Role")).toBeInTheDocument(); + }); + + test("creating a company validates the company name", () => { + renderContent(); + fireEvent.click(screen.getByText("I don't see my company")); + fireEvent.click(screen.getByText("Create Company & Role")); + expect(h.toastError).toHaveBeenCalledWith( + "Company name must be at least 3 characters.", + ); + expect(h.createCompanySpy).not.toHaveBeenCalled(); + }); + + test("creating a company validates the industry", () => { + renderContent(); + fireEvent.click(screen.getByText("I don't see my company")); + fireEvent.change(screen.getAllByPlaceholderText("Enter")[0]!, { + target: { value: "New Company" }, + }); + fireEvent.click(screen.getByText("Create Company & Role")); + expect(h.toastError).toHaveBeenCalledWith("Please select an industry."); + }); + + test("creating a company validates the location", () => { + renderContent({ industry: "Technology" }); + fireEvent.click(screen.getByText("I don't see my company")); + fireEvent.change(screen.getAllByPlaceholderText("Enter")[0]!, { + target: { value: "New Company" }, + }); + fireEvent.click(screen.getByText("Create Company & Role")); + expect(h.toastError).toHaveBeenCalledWith("Please select a location."); + }); + + test("creating a company validates the role title", () => { + renderContent({ industry: "Technology", locationId: "loc1", title: "" }); + fireEvent.click(screen.getByText("I don't see my company")); + fireEvent.change(screen.getAllByPlaceholderText("Enter")[0]!, { + target: { value: "New Company" }, + }); + fireEvent.click(screen.getByText("Create Company & Role")); + expect(h.toastError).toHaveBeenCalledWith( + "Role title must be at least 3 characters.", + ); + }); + + test("submits when every company field is provided", async () => { + renderContent({ + industry: "Technology", + locationId: "loc1", + title: "Engineer", + }); + fireEvent.click(screen.getByText("I don't see my company")); + fireEvent.change(screen.getAllByPlaceholderText("Enter")[0]!, { + target: { value: "New Company" }, + }); + fireEvent.click(screen.getByText("Create Company & Role")); + await waitFor(() => + expect(h.createCompanySpy).toHaveBeenCalledWith( + expect.objectContaining({ + companyName: "New Company", + industry: "Technology", + createdBy: "p1", + }), + ), + ); + }); + + test("shows a hint when the selected company has no roles", async () => { + h.state.roles = []; + renderContent(); + fireEvent.click(screen.getByTestId("select-Company")); + expect( + await screen.findByText(/No roles available for this company/), + ).toBeInTheDocument(); + }); + + // The role checkbox is the second checkbox on the page; its label is not + // wired with htmlFor, so we toggle the control directly. + const roleCheckbox = () => screen.getAllByRole("checkbox")[1]!; + + test("'I don't see my role' reveals the add-role form", () => { + renderContent(); + fireEvent.click(roleCheckbox()); + expect(screen.getByText("Add Your Role")).toBeInTheDocument(); + expect(screen.getByText("Create Role")).toBeInTheDocument(); + }); + + test("creating a role requires a company to be selected first", () => { + renderContent(); + fireEvent.click(roleCheckbox()); + fireEvent.click(screen.getByText("Create Role")); + expect(h.toastError).toHaveBeenCalledWith("Please select a company first."); + expect(h.createRoleSpy).not.toHaveBeenCalled(); + }); + + test("submits a new role once a company is selected and the title is valid", async () => { + renderContent(); + fireEvent.click(screen.getByTestId("select-Company")); + fireEvent.click(roleCheckbox()); + fireEvent.change(screen.getByPlaceholderText("Enter"), { + target: { value: "Backend Engineer" }, + }); + fireEvent.click(screen.getByText("Create Role")); + await waitFor(() => + expect(h.createRoleSpy).toHaveBeenCalledWith( + expect.objectContaining({ + title: "Backend Engineer", + companyId: "c1", + createdBy: "p1", + }), + ), + ); + }); + + test("rejects an invalid (too short) new role title", async () => { + renderContent(); + fireEvent.click(screen.getByTestId("select-Company")); + fireEvent.click(roleCheckbox()); + fireEvent.change(screen.getByPlaceholderText("Enter"), { + target: { value: "Dev" }, + }); + fireEvent.click(screen.getByText("Create Role")); + await waitFor(() => expect(h.toastError).toHaveBeenCalled()); + expect(h.createRoleSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/test/components/favorite-button.test.tsx b/apps/web/test/components/favorite-button.test.tsx new file mode 100644 index 00000000..3c995a0a --- /dev/null +++ b/apps/web/test/components/favorite-button.test.tsx @@ -0,0 +1,70 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { beforeEach, describe, expect, test, vi } from "vitest"; + +let hookReturn: { + isFavorited: boolean; + toggle: () => void; + isLoading: boolean; + profileId: string; +}; + +vi.mock("~/app/_components/shared/useFavoriteToggle", () => ({ + useFavoriteToggle: () => hookReturn, +})); + +vi.mock("next/image", () => ({ + default: ({ src, alt }: { src: string; alt: string }) => ( + {alt} + ), +})); + +import { FavoriteButton } from "~/app/_components/shared/favorite-button"; + +describe("FavoriteButton", () => { + beforeEach(() => { + hookReturn = { + isFavorited: false, + toggle: vi.fn(), + isLoading: false, + profileId: "profile-1", + }; + }); + + test("renders nothing without a profile", () => { + hookReturn = { ...hookReturn, profileId: "" }; + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + }); + + test("shows the empty bookmark when not favorited", () => { + render(); + expect(screen.getByAltText("Bookmark icon")).toHaveAttribute( + "src", + "/svg/bookmark.svg", + ); + }); + + test("shows the filled bookmark when favorited", () => { + hookReturn = { ...hookReturn, isFavorited: true }; + render(); + expect(screen.getByAltText("Bookmark icon")).toHaveAttribute( + "src", + "/svg/filledBookmark.svg", + ); + }); + + test("shows the hover bookmark on mouse enter when not favorited", () => { + render(); + fireEvent.mouseEnter(screen.getByRole("button")); + expect(screen.getByAltText("Bookmark icon")).toHaveAttribute( + "src", + "/svg/hoverBookmark.svg", + ); + }); + + test("calls toggle on click", () => { + render(); + fireEvent.click(screen.getByRole("button")); + expect(hookReturn.toggle).toHaveBeenCalledOnce(); + }); +}); diff --git a/apps/web/test/components/favorite-company-search.test.tsx b/apps/web/test/components/favorite-company-search.test.tsx new file mode 100644 index 00000000..0f79c6e2 --- /dev/null +++ b/apps/web/test/components/favorite-company-search.test.tsx @@ -0,0 +1,54 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, test, vi } from "vitest"; + +vi.mock("~/app/_components/themed/onboarding/input", () => ({ + Input: ({ + variant: _variant, + onClear: _onClear, + ...props + }: Record) => , +})); +vi.mock("~/app/_components/companies/company-card-preview", () => ({ + CompanyCardPreview: ({ companyObj }: { companyObj: { name: string } }) => ( +
{companyObj.name}
+ ), +})); + +import FavoriteCompanySearch from "~/app/_components/profile/favorite-company-search"; + +const companies = [ + { id: "1", name: "Apple" }, + { id: "2", name: "Google" }, +] as never[]; + +describe("FavoriteCompanySearch", () => { + test("lists all favorite companies by default", () => { + render(); + expect(screen.getAllByTestId("company-card")).toHaveLength(2); + }); + + test("filters companies by the typed prefix", () => { + render(); + fireEvent.change( + screen.getByPlaceholderText("Search for a saved company..."), + { target: { value: "goo" } }, + ); + const cards = screen.getAllByTestId("company-card"); + expect(cards).toHaveLength(1); + expect(cards[0]).toHaveTextContent("Google"); + }); + + test("shows an empty state when nothing matches", () => { + render(); + fireEvent.change( + screen.getByPlaceholderText("Search for a saved company..."), + { target: { value: "zzz" } }, + ); + expect(screen.getByText("No saved companies found.")).toBeInTheDocument(); + }); + + test("shows the empty state with no favorites", () => { + render(); + expect(screen.getByText("No saved companies found.")).toBeInTheDocument(); + }); +}); diff --git a/apps/web/test/components/favorite-role-search.test.tsx b/apps/web/test/components/favorite-role-search.test.tsx new file mode 100644 index 00000000..0d765b10 --- /dev/null +++ b/apps/web/test/components/favorite-role-search.test.tsx @@ -0,0 +1,70 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, test, vi } from "vitest"; + +vi.mock("~/app/_components/themed/onboarding/input", () => ({ + Input: ({ + variant: _variant, + onClear: _onClear, + ...props + }: Record) => , +})); +vi.mock("~/app/_components/roles/role-card-preview", () => ({ + RoleCardPreview: ({ roleObj }: { roleObj: { title: string } }) => ( +
{roleObj.title}
+ ), +})); +vi.mock("@cooper/ui", () => ({ + Pagination: ({ + currentPage, + totalPages, + }: { + currentPage: number; + totalPages: number; + }) => ( +
+ {currentPage}/{totalPages} +
+ ), +})); + +import FavoriteRoleSearch from "~/app/_components/profile/favorite-role-search"; + +const makeRoles = (n: number) => + Array.from({ length: n }, (_, i) => ({ + companyId: `c${i}`, + title: i === 0 ? "Designer" : `Engineer ${i}`, + })) as never[]; + +describe("FavoriteRoleSearch", () => { + test("renders a card per role within the first page", () => { + render(); + expect(screen.getAllByTestId("role-card")).toHaveLength(3); + }); + + test("paginates to 9 roles per page", () => { + render(); + expect(screen.getAllByTestId("role-card")).toHaveLength(9); + // ceil(12 / 9) = 2 pages + expect(screen.getByTestId("pagination")).toHaveTextContent("1/2"); + }); + + test("filters roles by the typed prefix", () => { + render(); + fireEvent.change( + screen.getByPlaceholderText("Search for a saved role..."), + { target: { value: "des" } }, + ); + const cards = screen.getAllByTestId("role-card"); + expect(cards).toHaveLength(1); + expect(cards[0]).toHaveTextContent("Designer"); + }); + + test("shows an empty state when nothing matches", () => { + render(); + fireEvent.change( + screen.getByPlaceholderText("Search for a saved role..."), + { target: { value: "zzz" } }, + ); + expect(screen.getByText("No saved roles found.")).toBeInTheDocument(); + }); +}); diff --git a/apps/web/test/components/filter-body.test.tsx b/apps/web/test/components/filter-body.test.tsx new file mode 100644 index 00000000..13f66db9 --- /dev/null +++ b/apps/web/test/components/filter-body.test.tsx @@ -0,0 +1,423 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, test, vi } from "vitest"; + +// Mock the heavy Autocomplete (portal-based) with a simple controllable stub. +vi.mock("@cooper/ui/autocomplete", () => ({ + default: ({ + options, + value, + onChange, + onSearchChange, + placeholder, + }: { + options: { value: string; label: string }[]; + value?: string[]; + onChange: (v: string[]) => void; + onSearchChange?: (s: string) => void; + placeholder?: string; + }) => ( +
+ onSearchChange?.(e.target.value)} + /> + {(value ?? []).join(",")} + {options.map((o) => ( + + ))} +
+ ), +})); + +import FilterBody from "~/app/_components/filters/filter-body"; + +const options = [ + { id: "a", label: "Apple", value: "apple" }, + { id: "b", label: "Banana", value: "banana" }, +]; + +describe("FilterBody - checkbox", () => { + test("renders options and toggles selection on", () => { + const onSelectionChange = vi.fn(); + render( + , + ); + expect(screen.getByText("Apple")).toBeInTheDocument(); + fireEvent.click(screen.getByText("Apple")); + expect(onSelectionChange).toHaveBeenCalledWith(["a"]); + }); + + test("toggles a selected option off", () => { + const onSelectionChange = vi.fn(); + render( + , + ); + fireEvent.click(screen.getByText("Apple")); + expect(onSelectionChange).toHaveBeenCalledWith([]); + }); + + test("no-op when onSelectionChange is missing", () => { + render( + , + ); + // Should not throw + fireEvent.click(screen.getByText("Apple")); + expect(screen.getByText("Apple")).toBeInTheDocument(); + }); + + test("shows a search box and filters when more than 5 options", () => { + const many = Array.from({ length: 6 }, (_, i) => ({ + id: `id${i}`, + label: `Label${i}`, + })); + render( + , + ); + const search = screen.getByPlaceholderText("Search..."); + fireEvent.change(search, { target: { value: "Label1" } }); + expect(screen.getByText("Label1")).toBeInTheDocument(); + expect(screen.queryByText("Label2")).toBeNull(); + }); + + test("defaults to checkbox for unknown variant", () => { + render( + , + ); + expect(screen.getByText("Apple")).toBeInTheDocument(); + }); +}); + +describe("FilterBody - rating", () => { + test("renders 5 star buttons", () => { + render( + , + ); + expect(screen.getByText("1.0")).toBeInTheDocument(); + expect(screen.getByText("5.0")).toBeInTheDocument(); + }); + + test("first click selects a single rating", () => { + const onSelectionChange = vi.fn(); + render( + , + ); + fireEvent.click(screen.getByText("3.0")); + expect(onSelectionChange).toHaveBeenCalledWith(["3"]); + }); + + test("clicking the same single rating clears it", () => { + const onSelectionChange = vi.fn(); + render( + , + ); + fireEvent.click(screen.getByText("3.0")); + expect(onSelectionChange).toHaveBeenCalledWith([]); + }); + + test("clicking a different rating builds a range", () => { + const onSelectionChange = vi.fn(); + render( + , + ); + fireEvent.click(screen.getByText("4.0")); + expect(onSelectionChange).toHaveBeenCalledWith(["2", "3", "4"]); + }); + + test("clicking with an existing range resets to single", () => { + const onSelectionChange = vi.fn(); + render( + , + ); + fireEvent.click(screen.getByText("5.0")); + expect(onSelectionChange).toHaveBeenCalledWith(["5"]); + }); + + test("no-op when onSelectionChange missing", () => { + render( + , + ); + fireEvent.click(screen.getByText("1.0")); + expect(screen.getByText("1.0")).toBeInTheDocument(); + }); +}); + +describe("FilterBody - range", () => { + test("renders empty inputs when default range (0,0)", () => { + const { container } = render( + , + ); + const min = container.querySelector("#min")!; + const max = container.querySelector("#max")!; + expect(min.value).toBe(""); + expect(max.value).toBe(""); + }); + + test("applies a valid range on blur", () => { + const onRangeChange = vi.fn(); + const { container } = render( + , + ); + const minInput = container.querySelector("#min")!; + const maxInput = container.querySelector("#max")!; + fireEvent.change(minInput, { target: { value: "10" } }); + fireEvent.change(maxInput, { target: { value: "20" } }); + fireEvent.blur(maxInput); + expect(onRangeChange).toHaveBeenCalledWith(10, 20); + }); + + test("shows error and does not apply when min >= max", () => { + const onRangeChange = vi.fn(); + const { container } = render( + , + ); + const minInput = container.querySelector("#min")!; + const maxInput = container.querySelector("#max")!; + fireEvent.change(minInput, { target: { value: "30" } }); + fireEvent.change(maxInput, { target: { value: "20" } }); + fireEvent.blur(maxInput); + expect( + screen.getByText("Minimum must be less than maximum"), + ).toBeInTheDocument(); + expect(onRangeChange).not.toHaveBeenCalled(); + }); + + test("clears the range when both inputs are empty", () => { + const onRangeChange = vi.fn(); + const { container } = render( + , + ); + const minInput = container.querySelector("#min")!; + const maxInput = container.querySelector("#max")!; + fireEvent.change(minInput, { target: { value: "" } }); + fireEvent.change(maxInput, { target: { value: "" } }); + fireEvent.blur(maxInput); + expect(onRangeChange).toHaveBeenCalledWith(0, 0); + }); + + test("applies min-only range (max becomes Infinity)", () => { + const onRangeChange = vi.fn(); + const { container } = render( + , + ); + const minInput = container.querySelector("#min")!; + fireEvent.change(minInput, { target: { value: "15" } }); + fireEvent.blur(minInput); + expect(onRangeChange).toHaveBeenCalledWith(15, Infinity); + }); + + test("applies on Enter key", () => { + const onRangeChange = vi.fn(); + const { container } = render( + , + ); + const minInput = container.querySelector("#min")!; + fireEvent.change(minInput, { target: { value: "5" } }); + fireEvent.keyDown(minInput, { key: "Enter" }); + expect(onRangeChange).toHaveBeenCalledWith(5, Infinity); + }); + + test("syncs local inputs from non-default props", () => { + const { container } = render( + , + ); + const minInput = container.querySelector("#min")!; + const maxInput = container.querySelector("#max")!; + expect(minInput.value).toBe("12"); + expect(maxInput.value).toBe("34"); + }); + + test("no-op when onRangeChange missing", () => { + const { container } = render( + , + ); + const minInput = container.querySelector("#min")!; + fireEvent.change(minInput, { target: { value: "5" } }); + fireEvent.blur(minInput); + // does not throw + expect(minInput).toBeDefined(); + }); +}); + +describe("FilterBody - autocomplete & location", () => { + test("autocomplete passes options through and reports selection", () => { + const onSelectionChange = vi.fn(); + render( + , + ); + fireEvent.click(screen.getByText("ac-Apple")); + expect(onSelectionChange).toHaveBeenCalledWith(["apple"]); + }); + + test("autocomplete forwards search changes", () => { + const onSearchChange = vi.fn(); + render( + , + ); + fireEvent.change(screen.getByLabelText("autocomplete-search"), { + target: { value: "ap" }, + }); + expect(onSearchChange).toHaveBeenCalledWith("ap"); + }); + + test("location variant reports selection using id", () => { + const onSelectionChange = vi.fn(); + render( + , + ); + fireEvent.click(screen.getByText("ac-Apple")); + expect(onSelectionChange).toHaveBeenCalledWith(["a"]); + }); +}); diff --git a/apps/web/test/components/header-layout.test.tsx b/apps/web/test/components/header-layout.test.tsx new file mode 100644 index 00000000..368c33cc --- /dev/null +++ b/apps/web/test/components/header-layout.test.tsx @@ -0,0 +1,55 @@ +import { render, screen } from "@testing-library/react"; +import { beforeEach, describe, expect, test, vi } from "vitest"; + +const { getSession } = vi.hoisted(() => ({ getSession: vi.fn() })); + +vi.mock("@cooper/auth", () => ({ getSession })); +vi.mock("~/app/_components/header/header", () => ({ + default: ({ + auth, + loggedIn, + }: { + auth: React.ReactNode; + loggedIn: unknown; + }) => ( +
+ {auth} +
+ ), +})); +vi.mock("~/app/_components/profile/profile-button", () => ({ + default: () =>
, +})); + +import HeaderLayout from "~/app/_components/header/header-layout"; + +describe("HeaderLayout", () => { + beforeEach(() => vi.clearAllMocks()); + + test("renders children inside the layout", async () => { + getSession.mockResolvedValue(null); + render(await HeaderLayout({ children:
})); + expect(screen.getByTestId("child")).toBeInTheDocument(); + expect(screen.getByTestId("header")).toBeInTheDocument(); + }); + + test("shows the profile button when there is a session", async () => { + getSession.mockResolvedValue({ user: { id: "u1" } }); + render(await HeaderLayout({ children:
})); + expect(screen.getByTestId("profile-button")).toBeInTheDocument(); + expect(screen.getByTestId("header")).toHaveAttribute( + "data-logged-in", + "true", + ); + }); + + test("omits the profile button for anonymous visitors", async () => { + getSession.mockResolvedValue(null); + render(await HeaderLayout({ children:
})); + expect(screen.queryByTestId("profile-button")).not.toBeInTheDocument(); + expect(screen.getByTestId("header")).toHaveAttribute( + "data-logged-in", + "false", + ); + }); +}); diff --git a/apps/web/test/components/info-icon.test.tsx b/apps/web/test/components/info-icon.test.tsx new file mode 100644 index 00000000..a1727144 --- /dev/null +++ b/apps/web/test/components/info-icon.test.tsx @@ -0,0 +1,35 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, test } from "vitest"; + +import { InfoIcon } from "~/app/_components/roles/modals/shared/info-icon"; + +describe("InfoIcon", () => { + test("renders the info trigger button", () => { + render(); + const button = screen.getByRole("button", { name: "More info" }); + expect(button).toBeInTheDocument(); + expect(button).toHaveTextContent("i"); + }); + + test("tooltip is hidden until hovered", () => { + render(); + expect(screen.queryByText("Helpful hint")).not.toBeInTheDocument(); + }); + + test("shows tooltip on hover and hides it when the pointer leaves", () => { + render(); + const button = screen.getByRole("button", { name: "More info" }); + + fireEvent.mouseEnter(button); + expect(screen.getByText("Helpful hint")).toBeInTheDocument(); + + fireEvent.mouseLeave(button); + expect(screen.queryByText("Helpful hint")).not.toBeInTheDocument(); + }); + + test("renders rich tooltip content", () => { + render(Rich} />); + fireEvent.mouseEnter(screen.getByRole("button", { name: "More info" })); + expect(screen.getByTestId("rich")).toBeInTheDocument(); + }); +}); diff --git a/apps/web/test/components/interview-modal.test.tsx b/apps/web/test/components/interview-modal.test.tsx new file mode 100644 index 00000000..3b1a77ad --- /dev/null +++ b/apps/web/test/components/interview-modal.test.tsx @@ -0,0 +1,74 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, test } from "vitest"; + +import { InterviewModal } from "~/app/_components/roles/modals/interview-modal"; + +const roleData = { + totalReviewsWithRounds: 5, + roundsMode: 3, + roundsDistribution: [ + { rounds: 2, count: 2 }, + { rounds: 3, count: 3 }, + ], + types: [ + { + type: "behavioral", + reviewCount: 3, + dominantDifficulty: "average" as const, + }, + { type: "technical", reviewCount: 2, dominantDifficulty: "hard" as const }, + ], + overallDominantDifficulty: "average" as const, + industryName: "TECHNOLOGY", +}; + +const distData = { + roundsMode: 3, + roundsDistribution: [{ rounds: 3, count: 4 }], +}; + +describe("InterviewModal", () => { + test("compact: renders the Interview title and types section", () => { + render( + , + ); + expect(screen.getByText("Interview")).toBeInTheDocument(); + expect(screen.getByText("Interview Types")).toBeInTheDocument(); + }); + + test("compact: shows the no-type fallback when there are no rounds", () => { + render( + , + ); + expect(screen.getByText("No interview type data")).toBeInTheDocument(); + }); + + test("full: renders the rounds panel with the tab toggle", () => { + render( + , + ); + expect(screen.getByText("Number of rounds")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Total" })).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Industry" }), + ).toBeInTheDocument(); + }); +}); diff --git a/apps/web/test/components/interview-round-item.test.tsx b/apps/web/test/components/interview-round-item.test.tsx new file mode 100644 index 00000000..8a043e4a --- /dev/null +++ b/apps/web/test/components/interview-round-item.test.tsx @@ -0,0 +1,116 @@ +import type { ReactNode } from "react"; +import { fireEvent, render, screen } from "@testing-library/react"; +import { FormProvider, useForm } from "react-hook-form"; +import { describe, expect, test, vi } from "vitest"; + +vi.mock("~/app/_components/filters/filter-body", () => ({ + default: ({ + title, + selectedOptions, + onSelectionChange, + }: { + title: string; + selectedOptions: string[]; + onSelectionChange: (selected: string[]) => void; + }) => ( +
+ {selectedOptions[0] ?? ""} + + +
+ ), +})); + +import { InterviewRoundItem } from "~/app/_components/form/sections/interview-round-item"; + +function Wrapper({ children }: { children: ReactNode }) { + const form = useForm({ defaultValues: { interviewRounds: [{}] } }); + return {children}; +} + +describe("InterviewRoundItem", () => { + test("renders the round number (1-indexed) and field labels", () => { + render( + + + , + ); + expect(screen.getByText("Round 1")).toBeInTheDocument(); + expect(screen.getByText("Interview type")).toBeInTheDocument(); + expect(screen.getByText("Difficulty")).toBeInTheDocument(); + }); + + test("renders a filter body for both selects", () => { + render( + + + , + ); + expect(screen.getByText("Round 3")).toBeInTheDocument(); + expect(screen.getAllByTestId("filter-body")).toHaveLength(2); + }); + + test("fires onRemove when the X button is clicked", () => { + const onRemove = vi.fn(); + render( + + + , + ); + // The X button is the only role="button"; the filter-body selects are + // queried by test id, so this resolves unambiguously. + fireEvent.click(screen.getByRole("button", { name: "" })); + expect(onRemove).toHaveBeenCalledOnce(); + }); + + test("selecting an interview type writes the value back into the field", () => { + render( + + + , + ); + expect(screen.getByTestId("selected-Interview type")).toHaveTextContent(""); + fireEvent.click(screen.getByTestId("select-Interview type")); + expect(screen.getByTestId("selected-Interview type")).toHaveTextContent( + "technical", + ); + }); + + test("clearing the interview type resets the field to undefined", () => { + render( + + + , + ); + fireEvent.click(screen.getByTestId("select-Interview type")); + expect(screen.getByTestId("selected-Interview type")).toHaveTextContent( + "technical", + ); + fireEvent.click(screen.getByTestId("clear-Interview type")); + expect(screen.getByTestId("selected-Interview type")).toHaveTextContent(""); + }); + + test("selecting and clearing the difficulty updates the field", () => { + render( + + + , + ); + fireEvent.click(screen.getByTestId("select-Difficulty")); + expect(screen.getByTestId("selected-Difficulty")).toHaveTextContent("hard"); + fireEvent.click(screen.getByTestId("clear-Difficulty")); + expect(screen.getByTestId("selected-Difficulty")).toHaveTextContent(""); + }); +}); diff --git a/apps/web/test/components/interview-section.test.tsx b/apps/web/test/components/interview-section.test.tsx new file mode 100644 index 00000000..2da31d43 --- /dev/null +++ b/apps/web/test/components/interview-section.test.tsx @@ -0,0 +1,51 @@ +import type { ReactNode } from "react"; +import { fireEvent, render, screen } from "@testing-library/react"; +import { FormProvider, useForm } from "react-hook-form"; +import { describe, expect, test, vi } from "vitest"; + +vi.mock("~/app/_components/form/sections/interview-round-item", () => ({ + InterviewRoundItem: ({ index }: { index: number }) => ( +
Round {index + 1}
+ ), +})); + +import { InterviewSection } from "~/app/_components/form/sections/interview-section"; + +function Wrapper({ children }: { children: ReactNode }) { + const form = useForm({ defaultValues: { interviewRounds: [] } }); + return {children}; +} + +describe("InterviewSection", () => { + test("renders the add-round button with no rounds initially", () => { + render( + + + , + ); + expect(screen.getByText("+ Add interview round")).toBeInTheDocument(); + expect(screen.queryByTestId("interview-round")).not.toBeInTheDocument(); + }); + + test("appends a round when the add button is clicked", () => { + render( + + + , + ); + fireEvent.click(screen.getByText("+ Add interview round")); + expect(screen.getByTestId("interview-round")).toBeInTheDocument(); + }); + + test("appends multiple rounds on repeated clicks", () => { + render( + + + , + ); + const button = screen.getByText("+ Add interview round"); + fireEvent.click(button); + fireEvent.click(button); + expect(screen.getAllByTestId("interview-round")).toHaveLength(2); + }); +}); diff --git a/apps/web/test/components/landing-page.test.tsx b/apps/web/test/components/landing-page.test.tsx new file mode 100644 index 00000000..6a217b2f --- /dev/null +++ b/apps/web/test/components/landing-page.test.tsx @@ -0,0 +1,41 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, test, vi } from "vitest"; + +const { envRef } = vi.hoisted(() => ({ + envRef: { VERCEL_ENV: "production" }, +})); + +vi.mock("~/env", () => ({ env: envRef })); + +vi.mock("~/app/_components/auth/admin-signin-button", () => ({ + default: () =>
, +})); +vi.mock("~/app/_components/auth/login-button", () => ({ + default: ({ isPreview }: { isPreview: boolean }) => ( +
{isPreview ? "preview" : "prod"}
+ ), +})); +vi.mock("~/app/_components/landing/admin-access-toast", () => ({ + AdminAccessToast: () =>
, +})); + +import Landing from "~/app/(pages)/(landing)/page"; + +describe("Landing page", () => { + test("renders the hero copy and value props", () => { + render(); + expect(screen.getByText("real co-op experiences")).toBeInTheDocument(); + expect( + screen.getByText("Anonymous reviews to protect identities"), + ).toBeInTheDocument(); + }); + + test("shows the husky email prompt and admin sign-in in production", () => { + render(); + expect( + screen.getByText("Log in with husky.neu.edu email to access reviews"), + ).toBeInTheDocument(); + expect(screen.getByTestId("admin-signin")).toBeInTheDocument(); + expect(screen.getByTestId("login-button")).toHaveTextContent("prod"); + }); +}); diff --git a/apps/web/test/components/layout.test.tsx b/apps/web/test/components/layout.test.tsx new file mode 100644 index 00000000..afe99a8f --- /dev/null +++ b/apps/web/test/components/layout.test.tsx @@ -0,0 +1,37 @@ +import { render, screen } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { describe, expect, test, vi } from "vitest"; + +vi.mock("~/env", () => ({ env: { VERCEL_ENV: "development" } })); +vi.mock("~/app/styles/font", () => ({ + hankenGroteskFont: { variable: "font-hanken" }, +})); +vi.mock("~/trpc/react", () => ({ + TRPCReactProvider: ({ children }: { children: ReactNode }) => ( +
{children}
+ ), +})); +vi.mock("~/app/_components/compare/compare-context", () => ({ + CompareProvider: ({ children }: { children: ReactNode }) => ( +
{children}
+ ), +})); + +import RootLayout, { metadata } from "~/app/layout"; + +describe("RootLayout", () => { + test("wraps children in the tRPC and compare providers", () => { + render( + + child content + , + ); + expect(screen.getByTestId("trpc-provider")).toBeInTheDocument(); + expect(screen.getByTestId("compare-provider")).toBeInTheDocument(); + expect(screen.getByText("child content")).toBeInTheDocument(); + }); + + test("exposes page metadata", () => { + expect(metadata.title).toBe("Cooper"); + }); +}); diff --git a/apps/web/test/components/loading-results.test.tsx b/apps/web/test/components/loading-results.test.tsx new file mode 100644 index 00000000..b72399f0 --- /dev/null +++ b/apps/web/test/components/loading-results.test.tsx @@ -0,0 +1,16 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, test } from "vitest"; + +import LoadingResults from "~/app/_components/loading-results"; + +describe("LoadingResults", () => { + test("renders the loading message", () => { + render(); + expect(screen.getByText("Loading ...")).toBeInTheDocument(); + }); + + test("renders the logo image", () => { + render(); + expect(screen.getByAltText("Logo Picture")).toBeInTheDocument(); + }); +}); diff --git a/apps/web/test/components/logos.test.tsx b/apps/web/test/components/logos.test.tsx new file mode 100644 index 00000000..9082fced --- /dev/null +++ b/apps/web/test/components/logos.test.tsx @@ -0,0 +1,30 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, test } from "vitest"; + +import BodyLogoWhite from "~/app/_components/body-logo-white"; +import BodyLogo from "~/app/_components/body-logo"; +import CooperLogo from "~/app/_components/cooper-logo"; + +describe("logo components", () => { + test("BodyLogo renders an image with default sizing", () => { + render(); + const img = screen.getByAltText("Logo Picture"); + expect(img).toBeInTheDocument(); + }); + + test("BodyLogo respects an explicit width", () => { + render(); + const img = screen.getByAltText("Logo Picture"); + expect(img.getAttribute("width")).toBe("117"); + }); + + test("BodyLogoWhite renders an image", () => { + render(); + expect(screen.getByAltText("Logo Picture")).toBeInTheDocument(); + }); + + test("CooperLogo renders an image", () => { + render(); + expect(screen.getByAltText("Logo Picture")).toBeInTheDocument(); + }); +}); diff --git a/apps/web/test/components/mobile-header-button.test.tsx b/apps/web/test/components/mobile-header-button.test.tsx new file mode 100644 index 00000000..9f28c3df --- /dev/null +++ b/apps/web/test/components/mobile-header-button.test.tsx @@ -0,0 +1,61 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { beforeEach, describe, expect, test, vi } from "vitest"; + +let pathname = "/"; + +vi.mock("next/navigation", () => ({ + usePathname: () => pathname, +})); + +import MobileHeaderButton from "~/app/_components/header/mobile-header-button"; + +describe("MobileHeaderButton", () => { + beforeEach(() => { + pathname = "/"; + }); + + test("renders the label", () => { + render(); + expect(screen.getByText("Home")).toBeInTheDocument(); + }); + + test("renders children", () => { + render( + + child node + , + ); + expect(screen.getByText("child node")).toBeInTheDocument(); + }); + + test("wraps the button in a link when href is provided", () => { + render(); + expect(screen.getByRole("link")).toHaveAttribute("href", "/roles"); + }); + + test("renders no link when href is omitted", () => { + render(); + expect(screen.queryByRole("link")).not.toBeInTheDocument(); + }); + + test("shows the selected indicator when the path matches href", () => { + pathname = "/roles"; + render(); + expect(screen.getByAltText("Selected")).toBeInTheDocument(); + }); + + test("hides the selected indicator when the path does not match", () => { + pathname = "/companies"; + render(); + expect(screen.queryByAltText("Selected")).not.toBeInTheDocument(); + }); + + test("fires onClick when the link is clicked", () => { + const onClick = vi.fn(); + render( + , + ); + fireEvent.click(screen.getByRole("link")); + expect(onClick).toHaveBeenCalled(); + }); +}); diff --git a/apps/web/test/components/modal-container.test.tsx b/apps/web/test/components/modal-container.test.tsx new file mode 100644 index 00000000..c5426634 --- /dev/null +++ b/apps/web/test/components/modal-container.test.tsx @@ -0,0 +1,33 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, test } from "vitest"; + +import ModalContainer from "~/app/_components/reviews/modal"; + +describe("ModalContainer", () => { + test("renders its children", () => { + render( + +

Inner content

+
, + ); + expect(screen.getByText("Inner content")).toBeInTheDocument(); + }); + + test("renders the title when provided", () => { + render( + + body + , + ); + expect(screen.getByText("Pay details")).toBeInTheDocument(); + }); + + test("omits the title node when not provided", () => { + render( + + body + , + ); + expect(screen.queryByText("Pay details")).not.toBeInTheDocument(); + }); +}); diff --git a/apps/web/test/components/new-role-dialogue.test.tsx b/apps/web/test/components/new-role-dialogue.test.tsx new file mode 100644 index 00000000..4c8c1148 --- /dev/null +++ b/apps/web/test/components/new-role-dialogue.test.tsx @@ -0,0 +1,57 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { beforeEach, describe, expect, test, vi } from "vitest"; + +const state: { + company?: { name: string }; + profile?: { id: string }; + createdRoles: unknown[]; +} = { createdRoles: [] }; + +vi.mock("@cooper/ui/hooks/use-custom-toast", () => ({ + useCustomToast: () => ({ toast: { success: vi.fn(), error: vi.fn() } }), +})); +vi.mock("~/trpc/react", () => ({ + api: { + company: { getById: { useQuery: () => ({ data: state.company }) } }, + profile: { getCurrentUser: { useQuery: () => ({ data: state.profile }) } }, + role: { + getByCreatedBy: { useQuery: () => ({ data: state.createdRoles }) }, + create: { useMutation: () => ({ mutateAsync: vi.fn() }) }, + }, + }, +})); + +import NewRoleDialog from "~/app/_components/roles/new-role-dialogue"; + +describe("NewRoleDialog", () => { + beforeEach(() => { + state.company = { name: "Acme" }; + state.profile = { id: "p1" }; + state.createdRoles = []; + }); + + test("renders the create-role trigger button", () => { + render(); + expect(screen.getByText("+ Create New Role")).toBeInTheDocument(); + }); + + test("opens the create form with company context", () => { + render(); + fireEvent.click(screen.getByText("+ Create New Role")); + expect( + screen.getByRole("heading", { name: "Create New Role" }), + ).toBeInTheDocument(); + expect(screen.getByText("Request a new role for Acme")).toBeInTheDocument(); + expect(screen.getByText("Role Name")).toBeInTheDocument(); + expect(screen.getByText("Description")).toBeInTheDocument(); + }); + + test("shows the limit message once the user has created more than 3 roles", () => { + state.createdRoles = [{}, {}, {}, {}]; + render(); + fireEvent.click(screen.getByText("+ Create New Role")); + expect( + screen.getByText("Sorry, you can only create up to 4 roles!"), + ).toBeInTheDocument(); + }); +}); diff --git a/apps/web/test/components/no-results.test.tsx b/apps/web/test/components/no-results.test.tsx new file mode 100644 index 00000000..5f9eab3e --- /dev/null +++ b/apps/web/test/components/no-results.test.tsx @@ -0,0 +1,29 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, test, vi } from "vitest"; + +const push = vi.fn(); +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push }), + usePathname: () => "/companies", +})); + +import NoResults from "~/app/_components/no-results"; + +describe("NoResults", () => { + test("renders the no results message", () => { + render(); + expect(screen.getByText("No Results Found")).toBeInTheDocument(); + }); + + test("does not show the clear button by default", () => { + render(); + expect(screen.queryByRole("button", { name: "Clear Filters" })).toBeNull(); + }); + + test("clears filters by pushing the current pathname", () => { + render(); + const button = screen.getByRole("button", { name: "Clear Filters" }); + fireEvent.click(button); + expect(push).toHaveBeenCalledWith("/companies"); + }); +}); diff --git a/apps/web/test/components/not-found.test.tsx b/apps/web/test/components/not-found.test.tsx new file mode 100644 index 00000000..46d037b2 --- /dev/null +++ b/apps/web/test/components/not-found.test.tsx @@ -0,0 +1,22 @@ +import { render, screen } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { describe, expect, test, vi } from "vitest"; + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ back: vi.fn() }), +})); + +// HeaderLayout pulls in auth/trpc; stub it to just render its children. +vi.mock("~/app/_components/header/header-layout", () => ({ + default: ({ children }: { children: ReactNode }) =>
{children}
, +})); + +import NotFound from "~/app/not-found"; + +describe("NotFound", () => { + test("renders the 404 page content", () => { + render(); + expect(screen.getByText("Page Not Found")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Go Back" })).toBeInTheDocument(); + }); +}); diff --git a/apps/web/test/components/on-the-job-modal.test.tsx b/apps/web/test/components/on-the-job-modal.test.tsx new file mode 100644 index 00000000..f7317500 --- /dev/null +++ b/apps/web/test/components/on-the-job-modal.test.tsx @@ -0,0 +1,81 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, test } from "vitest"; + +import { OnTheJobModal } from "~/app/_components/roles/modals/on-the-job-modal"; + +const averages = { + averageCultureRating: 4, + averageSupervisorRating: 4, + federalHolidays: 0.8, + drugTest: 0.2, + freeLunch: 0.5, + freeMerch: 0.5, + travelBenefits: 0.3, + snackBar: 0.4, + pto: 0.5, + totalReviews: 10, + workEnvironmentMode: "Hybrid", + workEnvironmentAlerts: [], + jobLengthMin: 4, + jobLengthMax: 6, + workHoursMin: 40, + workHoursMax: 40, + overtimeCount: 2, + accessibleByTransportation: 0.7, + teamOutingsCount: 5, + coffeeChatCount: 3, + constructiveFeedbackCount: 7, + onboarding: 0.9, + workStructure: 0.6, + careerGrowth: 0.8, + tools: ["React", "Figma"], +}; + +describe("OnTheJobModal", () => { + test("renders the section title and KPI tiles when comparing", () => { + render(); + expect(screen.getByText("On the Job")).toBeInTheDocument(); + expect(screen.getByText("Work model")).toBeInTheDocument(); + expect(screen.getByText("Hybrid")).toBeInTheDocument(); + expect(screen.getByText("Job length")).toBeInTheDocument(); + expect(screen.getByText("4-6 months")).toBeInTheDocument(); + }); + + test("renders the benefits table rows", () => { + render(); + expect(screen.getByText("Work Schedule")).toBeInTheDocument(); + expect(screen.getByText("40 hours per week")).toBeInTheDocument(); + expect(screen.getByText("Transportation")).toBeInTheDocument(); + expect( + screen.getByText("Accessible by transportation"), + ).toBeInTheDocument(); + expect(screen.getByText("Drug test")).toBeInTheDocument(); + expect(screen.getByText("Tools & software")).toBeInTheDocument(); + expect(screen.getByText("React, Figma")).toBeInTheDocument(); + }); + + test("renders the culture and support cards", () => { + render(); + expect(screen.getByText("Company culture")).toBeInTheDocument(); + expect(screen.getByText("Co-op support")).toBeInTheDocument(); + expect(screen.getByText("Onboarding")).toBeInTheDocument(); + // onboarding 0.9 -> 90% agree + expect(screen.getByText("90% agree")).toBeInTheDocument(); + }); + + test("shows No data fallbacks when there are no reviews", () => { + render( + , + ); + expect(screen.getAllByText("No data").length).toBeGreaterThan(0); + }); +}); diff --git a/apps/web/test/components/onboarding-dialog.test.tsx b/apps/web/test/components/onboarding-dialog.test.tsx new file mode 100644 index 00000000..265157ac --- /dev/null +++ b/apps/web/test/components/onboarding-dialog.test.tsx @@ -0,0 +1,81 @@ +import type { Session } from "@cooper/auth"; +import { render, screen } from "@testing-library/react"; +import { beforeEach, describe, expect, test, vi } from "vitest"; + +interface QueryState { + isPending: boolean; + data: unknown; +} + +const state: { + profile: QueryState; + roles: QueryState; +} = { + profile: { isPending: false, data: undefined }, + roles: { isPending: false, data: [] }, +}; + +vi.mock("~/trpc/react", () => ({ + api: { + profile: { + getCurrentUser: { useQuery: () => state.profile }, + }, + roleAndCompany: { + list: { useQuery: () => state.roles }, + }, + }, +})); + +vi.mock("~/app/_components/onboarding/onboarding-form", () => ({ + OnboardingForm: ({ userId }: { userId: string }) => ( +
{userId}
+ ), +})); + +import { OnboardingDialog } from "~/app/_components/onboarding/dialog"; + +const session = { + user: { id: "user-1", name: "Jane Doe", email: "jane@husky.neu.edu" }, +} as unknown as Session; + +describe("OnboardingDialog", () => { + beforeEach(() => { + state.profile = { isPending: false, data: undefined }; + state.roles = { isPending: false, data: [] }; + }); + + test("renders nothing when there is no session", () => { + render(); + expect(screen.queryByTestId("onboarding-form")).not.toBeInTheDocument(); + }); + + test("renders nothing while the profile query is pending", () => { + state.profile = { isPending: true, data: undefined }; + render(); + expect(screen.queryByTestId("onboarding-form")).not.toBeInTheDocument(); + }); + + test("renders nothing while the roles query is pending", () => { + state.roles = { isPending: true, data: undefined }; + render(); + expect(screen.queryByTestId("onboarding-form")).not.toBeInTheDocument(); + }); + + test("renders nothing when the user already has a profile", () => { + state.profile = { isPending: false, data: { id: "profile-1" } }; + render(); + expect(screen.queryByTestId("onboarding-form")).not.toBeInTheDocument(); + }); + + test("shows the onboarding form for a session without a profile", () => { + render(); + const form = screen.getByTestId("onboarding-form"); + expect(form).toBeInTheDocument(); + expect(form).toHaveTextContent("user-1"); + }); + + test("does not render the form when isOpen is false", () => { + render(); + expect(screen.queryByTestId("onboarding-form")).not.toBeInTheDocument(); + }); +}); diff --git a/apps/web/test/components/onboarding-form.test.tsx b/apps/web/test/components/onboarding-form.test.tsx new file mode 100644 index 00000000..834bac86 --- /dev/null +++ b/apps/web/test/components/onboarding-form.test.tsx @@ -0,0 +1,147 @@ +import type { Session } from "@cooper/auth"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, test, vi } from "vitest"; + +const h = vi.hoisted(() => ({ + mutate: vi.fn(), + isSuccess: false, +})); + +// DialogTitle requires a surrounding Radix Dialog context the form is normally +// rendered inside; render it as a plain heading in isolation. +vi.mock("@cooper/ui/dialog", () => ({ + DialogTitle: ({ children }: { children: React.ReactNode }) => ( +

{children}

+ ), +})); + +vi.mock("~/trpc/react", () => ({ + api: { + profile: { + create: { + useMutation: () => ({ mutate: h.mutate, isSuccess: h.isSuccess }), + }, + }, + }, +})); + +// ComboBox is a Radix popover; replace it with a simple button that selects a +// fixed major so the student form can be submitted deterministically. +vi.mock("~/app/_components/combo-box", () => ({ + default: ({ onSelect }: { onSelect: (value: string) => void }) => ( + + ), +})); + +import { OnboardingForm } from "~/app/_components/onboarding/onboarding-form"; + +const closeDialog = vi.fn(); + +function makeSession(role: string): Session { + return { + user: { + id: "user-1", + name: "Jane Doe", + email: "jane@husky.neu.edu", + role, + }, + } as unknown as Session; +} + +describe("OnboardingForm", () => { + beforeEach(() => { + vi.clearAllMocks(); + h.isSuccess = false; + }); + + test("renders the heading and prefills name/email from the session", () => { + render( + , + ); + expect(screen.getByText("Let’s get you setup")).toBeInTheDocument(); + expect(screen.getByDisplayValue("Jane")).toBeInTheDocument(); + expect(screen.getByDisplayValue("Doe")).toBeInTheDocument(); + expect(screen.getByDisplayValue("jane@husky.neu.edu")).toBeInTheDocument(); + }); + + test("hides student-only fields for a non-student", () => { + render( + , + ); + expect(screen.queryByText("Major")).not.toBeInTheDocument(); + expect(screen.queryByText("Graduation Year")).not.toBeInTheDocument(); + }); + + test("shows student-only fields for a student", () => { + render( + , + ); + expect(screen.getByText("Major")).toBeInTheDocument(); + expect(screen.getByText("Graduation Year")).toBeInTheDocument(); + expect(screen.getByText("Graduation Month")).toBeInTheDocument(); + }); + + test("submits only base fields for a non-student", async () => { + render( + , + ); + fireEvent.click(screen.getByRole("button", { name: "Finish" })); + + await waitFor(() => expect(h.mutate).toHaveBeenCalledTimes(1)); + expect(h.mutate).toHaveBeenCalledWith({ + userId: "user-1", + firstName: "Jane", + lastName: "Doe", + email: "jane@husky.neu.edu", + }); + }); + + test("does not submit a student form with missing required fields", async () => { + render( + , + ); + fireEvent.click(screen.getByRole("button", { name: "Finish" })); + + await waitFor(() => + expect(screen.getByText("Major is required")).toBeInTheDocument(), + ); + expect(h.mutate).not.toHaveBeenCalled(); + }); + + test("shows the welcome dialog once the profile is created", () => { + h.isSuccess = true; + render( + , + ); + expect(screen.getByText("Welcome to Cooper, Jane!")).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Start browsing" }), + ).toBeInTheDocument(); + }); +}); diff --git a/apps/web/test/components/onboarding-wrapper.test.tsx b/apps/web/test/components/onboarding-wrapper.test.tsx new file mode 100644 index 00000000..3b6ced29 --- /dev/null +++ b/apps/web/test/components/onboarding-wrapper.test.tsx @@ -0,0 +1,55 @@ +import type { Session } from "@cooper/auth"; +import { render, screen } from "@testing-library/react"; +import { beforeEach, describe, expect, test, vi } from "vitest"; + +const h = vi.hoisted(() => ({ + getSession: vi.fn(), +})); + +vi.mock("@cooper/auth", () => ({ + getSession: h.getSession, +})); + +vi.mock("~/app/_components/onboarding/dialog", () => ({ + OnboardingDialog: ({ session }: { session: Session | null }) => ( +
+ {session ? session.user.id : "no-session"} +
+ ), +})); + +import OnboardingWrapper from "~/app/_components/onboarding/onboarding-wrapper"; + +describe("OnboardingWrapper", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + test("renders children alongside the onboarding dialog", async () => { + h.getSession.mockResolvedValue(null); + render(await OnboardingWrapper({ children:

child content

})); + + expect(screen.getByText("child content")).toBeInTheDocument(); + expect(screen.getByTestId("onboarding-dialog")).toBeInTheDocument(); + }); + + test("passes a resolved session through to the dialog", async () => { + h.getSession.mockResolvedValue({ + user: { id: "user-42" }, + }); + render(await OnboardingWrapper({ children:

child

})); + + expect(screen.getByTestId("onboarding-dialog")).toHaveTextContent( + "user-42", + ); + }); + + test("passes a null session through when logged out", async () => { + h.getSession.mockResolvedValue(null); + render(await OnboardingWrapper({ children:

child

})); + + expect(screen.getByTestId("onboarding-dialog")).toHaveTextContent( + "no-session", + ); + }); +}); diff --git a/apps/web/test/components/pay-modal.test.tsx b/apps/web/test/components/pay-modal.test.tsx new file mode 100644 index 00000000..a71f0ae7 --- /dev/null +++ b/apps/web/test/components/pay-modal.test.tsx @@ -0,0 +1,71 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, test } from "vitest"; + +import { PayModal } from "~/app/_components/roles/modals/pay-modal"; + +const averages = { + averageHourlyPay: 25, + totalReviews: 10, + pto: 0.5, + travelBenefits: 0.3, + freeLunch: 0.8, +}; + +const payData = { + averageHourlyPay: 22, + totalReviews: 10, + payDistribution: [ + { label: "$20-30", min: 20, max: 30, count: 6 }, + { label: "$30-40", min: 30, max: 40, count: 4 }, + ], +}; + +describe("PayModal", () => { + test("compact: shows average pay and benefit rows", () => { + render( + , + ); + expect(screen.getByText("Pay")).toBeInTheDocument(); + expect(screen.getByText("$25/hr")).toBeInTheDocument(); + expect(screen.getByText("PTO")).toBeInTheDocument(); + expect(screen.getByText("Travel Stipend")).toBeInTheDocument(); + expect(screen.getByText("Free Lunch")).toBeInTheDocument(); + // pto 0.5 * 10 = 5 reported + expect(screen.getByText("5/10 reported")).toBeInTheDocument(); + }); + + test("full: shows the histogram heading and tab toggle", () => { + render( + , + ); + expect(screen.getByText("Average hourly pay")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Total" })).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Industry" }), + ).toBeInTheDocument(); + }); + + test("full: switching to Industry updates the average legend", () => { + render( + , + ); + fireEvent.click(screen.getByRole("button", { name: "Industry" })); + expect(screen.getByText(/\$18\/hr/)).toBeInTheDocument(); + }); +}); diff --git a/apps/web/test/components/pip-bar.test.tsx b/apps/web/test/components/pip-bar.test.tsx new file mode 100644 index 00000000..9fe1991c --- /dev/null +++ b/apps/web/test/components/pip-bar.test.tsx @@ -0,0 +1,47 @@ +import { render } from "@testing-library/react"; +import { describe, expect, test } from "vitest"; + +import { PipBar } from "~/app/_components/roles/modals/shared/pip-bar"; + +function getPips(container: HTMLElement) { + const track = container.firstElementChild!; + return Array.from(track.children) as HTMLElement[]; +} + +describe("PipBar", () => { + test("renders one pip per totalCount", () => { + const { container } = render( + , + ); + expect(getPips(container)).toHaveLength(5); + }); + + test("fills exactly filledCount pips with the given color", () => { + const { container } = render( + , + ); + const pips = getPips(container); + + expect(pips[0]!.style.backgroundColor).toBe("rgb(0, 128, 0)"); + expect(pips[1]!.style.backgroundColor).toBe("rgb(0, 128, 0)"); + // Unfilled pips get the gray class instead of an inline color. + expect(pips[2]!.style.backgroundColor).toBe(""); + expect(pips[2]!.className).toContain("bg-[#d3d3d3]"); + expect(pips[3]!.className).toContain("bg-[#d3d3d3]"); + }); + + test("clamps pip width to the max when there are few pips", () => { + const { container } = render( + , + ); + // A single pip would compute wider than the cap, so it is clamped to 24px. + expect(getPips(container)[0]!.style.width).toBe("24px"); + }); + + test("clamps pip width to the min when there are many pips", () => { + const { container } = render( + , + ); + expect(getPips(container)[0]!.style.width).toBe("4px"); + }); +}); diff --git a/apps/web/test/components/pip-card.test.tsx b/apps/web/test/components/pip-card.test.tsx new file mode 100644 index 00000000..2fc3d488 --- /dev/null +++ b/apps/web/test/components/pip-card.test.tsx @@ -0,0 +1,63 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, test } from "vitest"; + +import { PipCard } from "~/app/_components/roles/modals/shared/pip-card"; + +describe("PipCard", () => { + test("renders the name and subtext", () => { + render( + , + ); + expect(screen.getByText("Work-life balance")).toBeInTheDocument(); + expect( + screen.getByText("How sustainable the hours were"), + ).toBeInTheDocument(); + }); + + test("renders the pip bar with the requested number of pips", () => { + const { container } = render( + , + ); + // Each pip rendered by the embedded PipBar carries the h-9 class. + expect(container.querySelectorAll(".h-9")).toHaveLength(3); + }); + + test("omits the footer region when no footer is provided", () => { + render( + , + ); + expect(screen.queryByText("footer text")).not.toBeInTheDocument(); + }); + + test("renders the footer when provided", () => { + render( + footer text} + />, + ); + expect(screen.getByText("footer text")).toBeInTheDocument(); + }); +}); diff --git a/apps/web/test/components/popup.test.tsx b/apps/web/test/components/popup.test.tsx new file mode 100644 index 00000000..ee6e3cef --- /dev/null +++ b/apps/web/test/components/popup.test.tsx @@ -0,0 +1,51 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, test, vi } from "vitest"; + +import Popup from "~/app/_components/form/sections/popup"; + +function setup(showModal = true) { + const onSave = vi.fn(); + const onCancel = vi.fn(); + const onDiscard = vi.fn(); + const result = render( + , + ); + return { onSave, onCancel, onDiscard, ...result }; +} + +describe("Popup", () => { + test("renders nothing when showModal is false", () => { + const { container } = setup(false); + expect(container).toBeEmptyDOMElement(); + }); + + test("renders the draft prompt when showModal is true", () => { + setup(true); + // Rendered in both the desktop and mobile variants. + expect(screen.getAllByText("Save as Draft?").length).toBeGreaterThan(0); + }); + + test("Confirm triggers onSave", () => { + const { onSave } = setup(); + fireEvent.click(screen.getByText("Confirm")); + expect(onSave).toHaveBeenCalledOnce(); + }); + + test("Do not save triggers onDiscard", () => { + const { onDiscard } = setup(); + fireEvent.click(screen.getByText("Do not save")); + expect(onDiscard).toHaveBeenCalledOnce(); + }); + + test("Cancel triggers onCancel", () => { + const { onCancel } = setup(); + // The desktop "Cancel" and mobile "Cancel" both wire to onCancel. + fireEvent.click(screen.getAllByText("Cancel")[0]!); + expect(onCancel).toHaveBeenCalledOnce(); + }); +}); diff --git a/apps/web/test/components/profile-page.test.tsx b/apps/web/test/components/profile-page.test.tsx new file mode 100644 index 00000000..7bc9df20 --- /dev/null +++ b/apps/web/test/components/profile-page.test.tsx @@ -0,0 +1,191 @@ +import { render, screen } from "@testing-library/react"; +import { beforeEach, describe, expect, test, vi } from "vitest"; + +interface Query { + data: T; + isLoading: boolean; + error: unknown; +} + +const h = vi.hoisted(() => { + const make = (data: T): Query => ({ + data, + isLoading: false, + error: null, + }); + return { + make, + redirect: vi.fn(), + searchParams: new URLSearchParams(), + sessionQuery: make({ + user: { image: null, email: "jane@husky.neu.edu", role: "STUDENT" }, + }), + profileQuery: make({ + id: "p1", + firstName: "Jane", + lastName: "Doe", + graduationYear: 2025, + }), + reviewsQuery: { data: [] as unknown[] }, + favoriteRolesQuery: { data: [] as unknown[] }, + favoriteCompaniesQuery: { data: [] as unknown[] }, + }; +}); + +vi.mock("next/navigation", () => ({ + redirect: h.redirect, + useSearchParams: () => h.searchParams, +})); + +vi.mock("next/image", () => ({ + default: ({ alt, src }: { alt: string; src: string }) => ( + {alt} + ), +})); + +vi.mock("next/link", () => ({ + default: ({ + children, + href, + }: { + children: React.ReactNode; + href: string; + }) => {children}, +})); + +vi.mock("@cooper/ui/button", () => ({ + Button: ({ children }: { children: React.ReactNode }) => ( + + ), +})); + +vi.mock("~/app/_components/profile/favorite-company-search", () => ({ + default: () =>
, +})); +vi.mock("~/app/_components/profile/favorite-role-search", () => ({ + default: () =>
, +})); +vi.mock("~/app/_components/profile/profile-card-header", () => ({ + default: ({ email }: { email: string }) => ( +
{email}
+ ), +})); +vi.mock("~/app/_components/profile/profile-tabs", () => ({ + default: ({ numReviews }: { numReviews: number }) => ( +
{numReviews}
+ ), +})); +vi.mock("~/app/_components/reviews/draft-review-card", () => ({ + DraftReviewCard: ({ reviewObj }: { reviewObj: { id: string } }) => ( +
{reviewObj.id}
+ ), +})); +vi.mock("~/app/_components/reviews/profile-review-card", () => ({ + ProfileReviewCard: ({ reviewObj }: { reviewObj: { id: string } }) => ( +
{reviewObj.id}
+ ), +})); + +vi.mock("~/trpc/react", () => ({ + api: { + auth: { getSession: { useQuery: () => h.sessionQuery } }, + profile: { + getCurrentUser: { useQuery: () => h.profileQuery }, + listFavoriteRoles: { useQuery: () => h.favoriteRolesQuery }, + listFavoriteCompanies: { useQuery: () => h.favoriteCompaniesQuery }, + }, + review: { getByProfile: { useQuery: () => h.reviewsQuery } }, + role: { getById: () => ({}) }, + company: { getById: () => ({}) }, + useQueries: () => [], + }, +})); + +import Profile from "~/app/(pages)/(protected)/profile/page"; + +describe("Profile page", () => { + beforeEach(() => { + vi.clearAllMocks(); + h.searchParams = new URLSearchParams(); + h.sessionQuery = h.make({ + user: { image: null, email: "jane@husky.neu.edu", role: "STUDENT" }, + }); + h.profileQuery = h.make({ + id: "p1", + firstName: "Jane", + lastName: "Doe", + graduationYear: 2025, + }); + h.reviewsQuery = { data: [] }; + h.favoriteRolesQuery = { data: [] }; + h.favoriteCompaniesQuery = { data: [] }; + }); + + test("renders an error message when a query fails", () => { + h.profileQuery = { ...h.make(undefined), error: new Error("boom") }; + render(); + expect(screen.getByText(/Error loading profile/)).toBeInTheDocument(); + }); + + test("redirects home and renders nothing when there is no profile", () => { + h.profileQuery = h.make(undefined); + const { container } = render(); + expect(h.redirect).toHaveBeenCalledWith("/"); + expect(container).toBeEmptyDOMElement(); + }); + + test("renders the user's name, class year, header, and tabs", () => { + render(); + expect(screen.getByText("Jane Doe")).toBeInTheDocument(); + expect(screen.getByText("Class of 2025")).toBeInTheDocument(); + expect(screen.getByTestId("profile-card-header")).toHaveTextContent( + "jane@husky.neu.edu", + ); + expect(screen.getByTestId("profile-tabs")).toBeInTheDocument(); + }); + + test("defaults to the saved-roles tab", () => { + render(); + expect(screen.getByTestId("favorite-role-search")).toBeInTheDocument(); + expect( + screen.queryByTestId("favorite-company-search"), + ).not.toBeInTheDocument(); + }); + + test("shows the saved-companies tab when requested", () => { + h.searchParams = new URLSearchParams("tab=saved-companies"); + render(); + expect(screen.getByTestId("favorite-company-search")).toBeInTheDocument(); + expect( + screen.queryByTestId("favorite-role-search"), + ).not.toBeInTheDocument(); + }); + + test("shows an empty state on the reviews tab with no reviews", () => { + h.searchParams = new URLSearchParams("tab=my-reviews"); + render(); + expect(screen.getByText("No Reviews Yet")).toBeInTheDocument(); + }); + + test("renders drafts before published reviews on the reviews tab", () => { + h.searchParams = new URLSearchParams("tab=my-reviews"); + h.reviewsQuery = { + data: [ + { id: "published-1", status: "PUBLISHED", updatedAt: new Date(1) }, + { id: "draft-1", status: "DRAFT", updatedAt: new Date(2) }, + ], + }; + render(); + + const draft = screen.getByTestId("draft-review-card"); + const published = screen.getByTestId("profile-review-card"); + expect(draft).toHaveTextContent("draft-1"); + expect(published).toHaveTextContent("published-1"); + // Drafts sort ahead of published reviews. + expect( + draft.compareDocumentPosition(published) & + Node.DOCUMENT_POSITION_FOLLOWING, + ).toBeTruthy(); + expect(screen.queryByText("No Reviews Yet")).not.toBeInTheDocument(); + }); +}); diff --git a/apps/web/test/components/profile-review-card.test.tsx b/apps/web/test/components/profile-review-card.test.tsx new file mode 100644 index 00000000..8e0200f7 --- /dev/null +++ b/apps/web/test/components/profile-review-card.test.tsx @@ -0,0 +1,64 @@ +import { render, screen } from "@testing-library/react"; +import { beforeEach, describe, expect, test, vi } from "vitest"; + +const state: { + role?: { title: string }; + company?: { name: string }; + location?: { city: string; state: string; country: string }; +} = {}; + +vi.mock("next/image", () => ({ + default: ({ alt }: { alt: string }) => {alt}, +})); +vi.mock("~/trpc/react", () => ({ + api: { + role: { getById: { useQuery: () => ({ data: state.role }) } }, + company: { getById: { useQuery: () => ({ data: state.company }) } }, + location: { getById: { useQuery: () => ({ data: state.location }) } }, + }, +})); +vi.mock("~/app/_components/reviews/review-actions-dialogue", () => ({ + ReviewActionsDialog: () =>
, +})); + +import { ProfileReviewCard } from "~/app/_components/reviews/profile-review-card"; + +const review = { + id: "rev-1", + overallRating: 4.5, + roleId: "role-1", + companyId: "company-1", + locationId: "loc-1", + createdAt: new Date("2024-03-15T00:00:00Z"), +} as never; + +describe("ProfileReviewCard", () => { + beforeEach(() => { + state.role = { title: "Software Engineer" }; + state.company = { name: "Acme" }; + state.location = { city: "Boston", state: "MA", country: "USA" }; + }); + + test("renders the role title and rating", () => { + render(); + expect(screen.getByText("Software Engineer")).toBeInTheDocument(); + expect(screen.getByText("4.5")).toBeInTheDocument(); + }); + + test("renders a subtitle combining company and location", () => { + render(); + expect(screen.getByText(/Acme/)).toBeInTheDocument(); + }); + + test("falls back to an em dash when the role is unknown", () => { + state.role = undefined; + render(); + expect(screen.getByText("—")).toBeInTheDocument(); + }); + + test("renders the reviewed-on date and actions menu", () => { + render(); + expect(screen.getByText(/Reviewed on/)).toBeInTheDocument(); + expect(screen.getByTestId("review-actions")).toBeInTheDocument(); + }); +}); diff --git a/apps/web/test/components/profile-tabs.test.tsx b/apps/web/test/components/profile-tabs.test.tsx new file mode 100644 index 00000000..bfa9cb85 --- /dev/null +++ b/apps/web/test/components/profile-tabs.test.tsx @@ -0,0 +1,61 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { beforeEach, describe, expect, test, vi } from "vitest"; + +const push = vi.fn(); +let sessionData: { user: { role: string } } | undefined; +let searchParams = new URLSearchParams(); + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push }), + usePathname: () => "/profile", + useSearchParams: () => searchParams, +})); + +vi.mock("~/trpc/react", () => ({ + api: { + auth: { + getSession: { useQuery: () => ({ data: sessionData }) }, + }, + }, +})); + +import ProfileTabs from "~/app/_components/profile/profile-tabs"; + +describe("ProfileTabs", () => { + beforeEach(() => { + vi.clearAllMocks(); + searchParams = new URLSearchParams(); + sessionData = { user: { role: "STUDENT" } }; + }); + + test("shows the My reviews tab for students", () => { + render(); + expect(screen.getByText("Saved roles")).toBeInTheDocument(); + expect(screen.getByText("Saved companies")).toBeInTheDocument(); + expect(screen.getByText(/My reviews/)).toBeInTheDocument(); + }); + + test("includes the review count on the My reviews tab", () => { + render(); + expect(screen.getByText(/My reviews \(7\)/)).toBeInTheDocument(); + }); + + test("hides the My reviews tab for non-student roles", () => { + sessionData = { user: { role: "EMPLOYER" } }; + render(); + expect(screen.queryByText(/My reviews/)).not.toBeInTheDocument(); + }); + + test("clicking a tab pushes the tab query param", () => { + render(); + fireEvent.click(screen.getByText("Saved companies")); + expect(push).toHaveBeenCalledWith("/profile?tab=saved-companies"); + }); + + test("preserves existing query params when switching tabs", () => { + searchParams = new URLSearchParams("foo=bar"); + render(); + fireEvent.click(screen.getByText("Saved companies")); + expect(push).toHaveBeenCalledWith("/profile?foo=bar&tab=saved-companies"); + }); +}); diff --git a/apps/web/test/components/protected-layouts.test.tsx b/apps/web/test/components/protected-layouts.test.tsx new file mode 100644 index 00000000..2e684acb --- /dev/null +++ b/apps/web/test/components/protected-layouts.test.tsx @@ -0,0 +1,72 @@ +import { render, screen } from "@testing-library/react"; +import { beforeEach, describe, expect, test, vi } from "vitest"; + +const { getSession, redirect, findFirst } = vi.hoisted(() => ({ + getSession: vi.fn(), + redirect: vi.fn(), + findFirst: vi.fn(), +})); + +vi.mock("next/navigation", () => ({ redirect })); +vi.mock("@cooper/auth", () => ({ getSession })); +vi.mock("@cooper/db/client", () => ({ + db: { query: { User: { findFirst } } }, +})); +vi.mock("@cooper/ui", () => ({ + CustomToaster: () =>
, +})); +vi.mock("~/app/_components/header/header-layout", () => ({ + default: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), +})); +vi.mock("~/app/_components/onboarding/onboarding-wrapper", () => ({ + default: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), +})); + +import LandingLayout from "~/app/(pages)/(landing)/layout"; +import ProtectedLayout from "~/app/(pages)/(protected)/layout"; + +describe("(landing) RootLayout", () => { + beforeEach(() => vi.clearAllMocks()); + + test("redirects authenticated users to /roles", async () => { + getSession.mockResolvedValue({ user: { id: "u1" } }); + await LandingLayout({ children:
}); + expect(redirect).toHaveBeenCalledWith("/roles"); + }); + + test("renders children for anonymous visitors", async () => { + getSession.mockResolvedValue(null); + render(await LandingLayout({ children:
})); + expect(redirect).not.toHaveBeenCalled(); + expect(screen.getByTestId("child")).toBeInTheDocument(); + }); +}); + +describe("(protected) ProtectedLayout", () => { + beforeEach(() => vi.clearAllMocks()); + + test("redirects to / when there is no session", async () => { + getSession.mockResolvedValue(null); + await ProtectedLayout({ children:
}); + expect(redirect).toHaveBeenCalledWith("/"); + }); + + test("redirects disabled users to /", async () => { + getSession.mockResolvedValue({ user: { id: "u1" } }); + findFirst.mockResolvedValue({ isDisabled: true }); + await ProtectedLayout({ children:
}); + expect(redirect).toHaveBeenCalledWith("/"); + }); + + test("renders children for an enabled, authenticated user", async () => { + getSession.mockResolvedValue({ user: { id: "u1" } }); + findFirst.mockResolvedValue({ isDisabled: false }); + render(await ProtectedLayout({ children:
})); + expect(redirect).not.toHaveBeenCalled(); + expect(screen.getByTestId("child")).toBeInTheDocument(); + }); +}); diff --git a/apps/web/test/components/report-button.test.tsx b/apps/web/test/components/report-button.test.tsx new file mode 100644 index 00000000..58a5e3ac --- /dev/null +++ b/apps/web/test/components/report-button.test.tsx @@ -0,0 +1,203 @@ +import { act, fireEvent, render, screen } from "@testing-library/react"; +import { beforeEach, describe, expect, test, vi } from "vitest"; + +const h = vi.hoisted(() => ({ + success: vi.fn(), + error: vi.fn(), + mutate: vi.fn(), + isPending: false, + opts: undefined as + | { onSuccess: () => void; onError: (err: { message: string }) => void } + | undefined, +})); + +vi.mock("@cooper/ui/hooks/use-custom-toast", () => ({ + useCustomToast: () => ({ toast: { success: h.success, error: h.error } }), +})); + +vi.mock("next/image", () => ({ + default: ({ alt }: { alt: string }) => {alt}, +})); + +// A plain button stand-in. Unlike the real one it never sets the DOM +// `disabled` attribute, so we can still click "Cancel" while a submit is +// pending and exercise the closeReportModal guard. +vi.mock("@cooper/ui/button", () => ({ + Button: ({ + children, + onClick, + type, + }: { + children: React.ReactNode; + onClick?: () => void; + type?: "button" | "submit"; + }) => ( + + ), +})); + +// Lightweight Select: renders the items (so the option map runs) and exposes a +// button that fires onValueChange with a real ReportReason value. +vi.mock("@cooper/ui/select", () => ({ + Select: ({ + value, + onValueChange, + children, + }: { + value: string; + onValueChange: (v: string) => void; + children: React.ReactNode; + }) => ( +
+
+ ), + SelectContent: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + SelectItem: ({ + value, + children, + }: { + value: string; + children: React.ReactNode; + }) =>
{children}
, + SelectTrigger: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + SelectValue: ({ placeholder }: { placeholder: string }) => ( + {placeholder} + ), +})); + +vi.mock("~/trpc/react", () => ({ + api: { + report: { + create: { + useMutation: (opts: typeof h.opts) => { + h.opts = opts; + return { mutate: h.mutate, isPending: h.isPending }; + }, + }, + }, + }, +})); + +import { ReportButton } from "~/app/_components/shared/report-button"; + +function open() { + fireEvent.click(screen.getByText("Report")); +} + +describe("ReportButton", () => { + beforeEach(() => { + vi.clearAllMocks(); + h.isPending = false; + h.opts = undefined; + }); + + test("shows the Report label by default", () => { + render(); + expect(screen.getByText("Report")).toBeInTheDocument(); + }); + + test("hides the label in icon-only mode", () => { + render(); + expect(screen.queryByText("Report")).not.toBeInTheDocument(); + expect(screen.getByAltText("Report")).toBeInTheDocument(); + }); + + test("opens the report dialog when the trigger is clicked", () => { + render(); + open(); + expect(screen.getByText("Report content")).toBeInTheDocument(); + }); + + test("blocks submission and warns when no reason is selected", () => { + render(); + open(); + fireEvent.click(screen.getByText("Submit report")); + + expect(h.error).toHaveBeenCalledWith("Please select a report reason."); + expect(h.mutate).not.toHaveBeenCalled(); + }); + + test("warns when a reason is set but the description is empty", () => { + render(); + open(); + fireEvent.click(screen.getByTestId("pick-reason")); + fireEvent.click(screen.getByText("Submit report")); + + expect(h.error).toHaveBeenCalledWith("Please enter a report description."); + expect(h.mutate).not.toHaveBeenCalled(); + }); + + test("submits the report with the reason and a trimmed description", () => { + render(); + open(); + fireEvent.click(screen.getByTestId("pick-reason")); + fireEvent.change( + screen.getByPlaceholderText( + "Add details to help moderators understand the issue", + ), + { target: { value: " spammy listing " } }, + ); + fireEvent.click(screen.getByText("Submit report")); + + expect(h.mutate).toHaveBeenCalledWith({ + entityType: "company", + entityId: "co-9", + reason: "SPAM", + reportText: "spammy listing", + }); + }); + + test("notifies and closes the dialog on a successful submission", () => { + render(); + open(); + expect(screen.getByText("Report content")).toBeInTheDocument(); + + act(() => h.opts?.onSuccess()); + + expect(h.success).toHaveBeenCalledWith( + "Thanks for your report. Our team will review it.", + ); + expect(screen.queryByText("Report content")).not.toBeInTheDocument(); + }); + + test("surfaces the server error message on failure", () => { + render(); + act(() => h.opts?.onError({ message: "Boom" })); + expect(h.error).toHaveBeenCalledWith("Boom"); + }); + + test("falls back to a generic error message", () => { + render(); + act(() => h.opts?.onError({ message: "" })); + expect(h.error).toHaveBeenCalledWith( + "Unable to submit report. Please try again.", + ); + }); + + test("Cancel closes the dialog when no submit is in flight", () => { + render(); + open(); + fireEvent.click(screen.getByText("Cancel")); + expect(screen.queryByText("Report content")).not.toBeInTheDocument(); + }); + + test("Cancel is a no-op while a submit is pending", () => { + h.isPending = true; + render(); + open(); + fireEvent.click(screen.getByText("Cancel")); + expect(screen.getByText("Report content")).toBeInTheDocument(); + }); +}); diff --git a/apps/web/test/components/review-actions-dialogue.test.tsx b/apps/web/test/components/review-actions-dialogue.test.tsx new file mode 100644 index 00000000..781682a7 --- /dev/null +++ b/apps/web/test/components/review-actions-dialogue.test.tsx @@ -0,0 +1,61 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, test, vi } from "vitest"; + +vi.mock("~/app/_components/reviews/delete-review-dialogue", () => ({ + DeleteReviewDialog: ({ trigger }: { trigger: React.ReactNode }) => ( +
{trigger}
+ ), +})); +vi.mock("~/app/_components/reviews/review-view-edit-modal", () => ({ + ReviewViewEditModal: ({ open }: { open: boolean }) => ( +
{open ? "open" : "closed"}
+ ), +})); + +import { ReviewActionsDialog } from "~/app/_components/reviews/review-actions-dialogue"; + +const publishedReview = { id: "rev-1", status: "PUBLISHED" } as never; +const draftReview = { id: "rev-2", status: "DRAFT" } as never; + +function open(review: never) { + render( + Open menu} + />, + ); + fireEvent.click(screen.getByText("Open menu")); +} + +describe("ReviewActionsDialog", () => { + test("renders the trigger", () => { + render( + Open menu} + />, + ); + expect(screen.getByText("Open menu")).toBeInTheDocument(); + }); + + test("shows View/Edit/Delete actions for a published review", () => { + open(publishedReview); + expect(screen.getByText("View Review")).toBeInTheDocument(); + expect(screen.getByText("Edit Review")).toBeInTheDocument(); + expect(screen.getByText("Delete Review")).toBeInTheDocument(); + }); + + test("uses draft labels and hides View for a draft", () => { + open(draftReview); + expect(screen.queryByText("View Review")).not.toBeInTheDocument(); + expect(screen.getByText("Edit Draft")).toBeInTheDocument(); + expect(screen.getByText("Delete Draft")).toBeInTheDocument(); + }); + + test("opens the view/edit modal when Edit is clicked", () => { + open(publishedReview); + expect(screen.getByTestId("view-edit-modal")).toHaveTextContent("closed"); + fireEvent.click(screen.getByText("Edit Review")); + expect(screen.getByTestId("view-edit-modal")).toHaveTextContent("open"); + }); +}); diff --git a/apps/web/test/components/review-card-stars.test.tsx b/apps/web/test/components/review-card-stars.test.tsx new file mode 100644 index 00000000..a40f5273 --- /dev/null +++ b/apps/web/test/components/review-card-stars.test.tsx @@ -0,0 +1,55 @@ +import { render } from "@testing-library/react"; +import { describe, expect, test } from "vitest"; + +import { + GrayStar, + ReviewCardStars, + YellowStar, +} from "~/app/_components/reviews/review-card-stars"; + +function countByFill(container: HTMLElement, fill: string) { + return container.querySelectorAll(`path[fill="${fill}"]`).length; +} + +const YELLOW = "#FF9900"; +const GRAY = "#C1C1C1"; + +describe("ReviewCardStars", () => { + test("renders five yellow stars for a perfect rating", () => { + const { container } = render(); + expect(countByFill(container, YELLOW)).toBe(5); + expect(countByFill(container, GRAY)).toBe(0); + }); + + test("renders full and empty stars for a whole-number rating", () => { + const { container } = render(); + expect(countByFill(container, YELLOW)).toBe(3); + expect(countByFill(container, GRAY)).toBe(2); + }); + + test("renders a fractional star overlay", () => { + const { container } = render(); + // 3 full yellow + 1 overlay yellow (over a gray) = 4 yellow paths + expect(countByFill(container, YELLOW)).toBe(4); + // 1 gray under the fraction + 1 trailing empty = 2 gray paths + expect(countByFill(container, GRAY)).toBe(2); + }); + + test("renders all gray stars for a zero rating", () => { + const { container } = render(); + expect(countByFill(container, YELLOW)).toBe(0); + expect(countByFill(container, GRAY)).toBe(5); + }); +}); + +describe("Star icons", () => { + test("YellowStar applies its className", () => { + const { container } = render(); + expect(container.querySelector("svg")).toHaveClass("custom-class"); + }); + + test("GrayStar applies its className", () => { + const { container } = render(); + expect(container.querySelector("svg")).toHaveClass("custom-class"); + }); +}); diff --git a/apps/web/test/components/review-card.test.tsx b/apps/web/test/components/review-card.test.tsx new file mode 100644 index 00000000..bd059b74 --- /dev/null +++ b/apps/web/test/components/review-card.test.tsx @@ -0,0 +1,62 @@ +import { render, screen } from "@testing-library/react"; +import { beforeEach, describe, expect, test, vi } from "vitest"; + +import type { ReviewType } from "@cooper/db/schema"; + +let locationData: { city: string; state: string; country: string } | undefined; + +vi.mock("next/image", () => ({ + default: ({ alt }: { alt: string }) => {alt}, +})); +vi.mock("~/trpc/react", () => ({ + api: { + location: { getById: { useQuery: () => ({ data: locationData }) } }, + }, +})); +vi.mock("~/app/_components/shared/report-button", () => ({ + ReportButton: ({ entityId }: { entityId: string }) => ( +
{entityId}
+ ), +})); + +import { ReviewCard } from "~/app/_components/reviews/review-card"; + +const baseReview = { + id: "rev-1", + overallRating: 4.2, + workTerm: "SPRING", + workYear: 2024, + textReview: "Great experience overall", + jobType: "CO-OP", + workEnvironment: "REMOTE", + hourlyPay: "25", + locationId: "loc-1", +} as unknown as ReviewType; + +describe("ReviewCard", () => { + beforeEach(() => { + locationData = { city: "Boston", state: "MA", country: "USA" }; + }); + + test("renders the rating, term, and pay", () => { + render(); + expect(screen.getByText("4.2")).toBeInTheDocument(); + expect(screen.getByText(/Spring/)).toBeInTheDocument(); + expect(screen.getByText("25", { exact: false })).toBeInTheDocument(); + }); + + test("maps CO-OP job type to the friendly label", () => { + render(); + expect(screen.getAllByText("Co-op").length).toBeGreaterThan(0); + }); + + test("falls back to N/A when there is no overall rating", () => { + render(); + expect(screen.getByText("N/A")).toBeInTheDocument(); + }); + + test("renders the report button for the review", () => { + render(); + expect(screen.getByTestId("report-button")).toHaveTextContent("rev-1"); + }); +}); diff --git a/apps/web/test/components/review-form-page.test.tsx b/apps/web/test/components/review-form-page.test.tsx new file mode 100644 index 00000000..ec93158e --- /dev/null +++ b/apps/web/test/components/review-form-page.test.tsx @@ -0,0 +1,197 @@ +import { act, fireEvent, render, screen } from "@testing-library/react"; +import { beforeEach, describe, expect, test, vi } from "vitest"; + +interface Query { + data: T; + isLoading: boolean; + error: unknown; +} + +const h = vi.hoisted(() => { + const make = (data: T): Query => ({ + data, + isLoading: false, + error: null, + }); + return { + make, + push: vi.fn(), + replace: vi.fn(), + invalidate: vi.fn(), + toastError: vi.fn(), + toastSuccess: vi.fn(), + createMutateAsync: vi.fn().mockResolvedValue({ id: "rev-1" }), + saveDraftMutateAsync: vi.fn().mockResolvedValue({ id: "draft-1" }), + updateMutateAsync: vi.fn().mockResolvedValue({ id: "draft-1" }), + sessionQuery: make({ + user: { email: "a@b.com", role: "STUDENT" }, + }), + profileQuery: make({ id: "p1" }), + reviewsQuery: { data: [] as unknown[] }, + }; +}); + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push: h.push, replace: h.replace }), +})); + +vi.mock("@cooper/ui", () => ({ + useCustomToast: () => ({ + toast: { error: h.toastError, success: h.toastSuccess }, + }), +})); + +vi.mock("@cooper/ui/button", () => ({ + Button: ({ + children, + onClick, + disabled, + type, + }: { + children: React.ReactNode; + onClick?: () => void; + disabled?: boolean; + type?: "button" | "submit"; + }) => ( + + ), +})); + +vi.mock("@cooper/ui/form", () => ({ + Form: ({ children }: { children: React.ReactNode }) =>
{children}
, +})); + +vi.mock("~/app/_components/form/sections", () => ({ + BasicInfoSection: () =>
, + CompanyDetailsSection: () =>
, + InterviewSection: () =>
, + ReviewSection: () =>
, +})); +vi.mock("~/app/_components/form/sections/pay-section", () => ({ + PaySection: () =>
, +})); +vi.mock("~/app/_components/form/sections/popup", () => ({ + default: ({ + onSave, + onDiscard, + }: { + onSave: () => void; + onDiscard: () => void; + }) => ( +
+ + +
+ ), +})); + +vi.mock("~/trpc/react", () => ({ + api: { + useUtils: () => ({ + review: { getByProfile: { invalidate: h.invalidate } }, + }), + auth: { getSession: { useQuery: () => h.sessionQuery } }, + profile: { getCurrentUser: { useQuery: () => h.profileQuery } }, + review: { + getByProfile: { useQuery: () => h.reviewsQuery }, + create: { + useMutation: () => ({ + mutateAsync: h.createMutateAsync, + isPending: false, + }), + }, + saveDraft: { + useMutation: () => ({ + mutateAsync: h.saveDraftMutateAsync, + isPending: false, + }), + }, + update: { + useMutation: () => ({ + mutateAsync: h.updateMutateAsync, + isPending: false, + }), + }, + }, + }, +})); + +import ReviewForm from "~/app/(pages)/(protected)/review-form/page"; + +describe("ReviewForm page", () => { + beforeEach(() => { + vi.clearAllMocks(); + h.sessionQuery = h.make({ user: { email: "a@b.com", role: "STUDENT" } }); + h.profileQuery = h.make({ id: "p1" }); + h.reviewsQuery = { data: [] }; + }); + + test("renders nothing while session and profile are still loading", () => { + h.sessionQuery = { ...h.make(undefined), isLoading: true }; + h.profileQuery = { ...h.make(undefined), isLoading: true }; + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + }); + + test("redirects to /roles when settled without a session", () => { + h.sessionQuery = h.make(undefined); + h.profileQuery = h.make(undefined); + render(); + expect(h.push).toHaveBeenCalledWith("/roles"); + }); + + test("renders all form sections for a student", () => { + render(); + expect(screen.getByTestId("basic-info-section")).toBeInTheDocument(); + expect(screen.getByTestId("company-details-section")).toBeInTheDocument(); + expect(screen.getByTestId("pay-section")).toBeInTheDocument(); + expect(screen.getByTestId("interview-section")).toBeInTheDocument(); + expect(screen.getByTestId("review-section")).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Submit review" }), + ).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Save draft" }), + ).toBeInTheDocument(); + }); + + test("redirects employers to /404", () => { + h.sessionQuery = h.make({ user: { email: "a@b.com", role: "EMPLOYER" } }); + render(); + expect(h.replace).toHaveBeenCalledWith("/404"); + }); + + test("blocks submit and toasts when required fields are empty", async () => { + render(); + await act(async () => { + fireEvent.click(screen.getByRole("button", { name: "Submit review" })); + }); + expect(h.toastError).toHaveBeenCalledWith( + "Please fill in all required fields.", + ); + expect(h.createMutateAsync).not.toHaveBeenCalled(); + }); + + test("saving a new draft calls the saveDraft mutation and toasts success", async () => { + render(); + await act(async () => { + fireEvent.click(screen.getByRole("button", { name: "Save draft" })); + }); + expect(h.saveDraftMutateAsync).toHaveBeenCalledTimes(1); + expect(h.toastSuccess).toHaveBeenCalledWith("This draft has been saved."); + }); + + test("a leave attempt with a pristine form navigates to /roles", () => { + render(); + act(() => { + window.dispatchEvent(new Event("review-form:leave-attempt")); + }); + expect(h.push).toHaveBeenCalledWith("/roles"); + }); +}); diff --git a/apps/web/test/components/review-modal.test.tsx b/apps/web/test/components/review-modal.test.tsx new file mode 100644 index 00000000..9506842b --- /dev/null +++ b/apps/web/test/components/review-modal.test.tsx @@ -0,0 +1,362 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { beforeEach, describe, expect, test, vi } from "vitest"; + +interface Review { + id: string; + overallRating?: number; + workYear?: number; + workTerm?: string; + locationId?: string | null; + jobType?: string; + status?: string; +} + +const state: { + reviews: { data: Review[]; isSuccess: boolean }; + usersReviews: { data: { status: string }[]; isSuccess: boolean }; + list: { data: { overallRating: number }[] }; + byPopularity: { data: { id: string }[] }; +} = { + reviews: { data: [], isSuccess: true }, + usersReviews: { data: [], isSuccess: true }, + list: { data: [] }, + byPopularity: { data: [] }, +}; + +vi.mock("next/link", () => ({ + default: ({ + children, + href, + }: { + children: React.ReactNode; + href: string; + }) => {children}, +})); + +vi.mock("~/trpc/react", () => ({ + api: { + review: { + getByRole: { + useQuery: () => ({ + data: state.reviews.data, + isSuccess: state.reviews.isSuccess, + }), + }, + list: { useQuery: () => ({ data: state.list.data }) }, + getByProfile: { + useQuery: () => ({ + data: state.usersReviews.data, + isSuccess: state.usersReviews.isSuccess, + }), + }, + }, + role: { + getAverageById: { + useQuery: () => ({ data: { averageOverallRating: 4.2 } }), + }, + }, + profile: { getCurrentUser: { useQuery: () => ({ data: { id: "p1" } }) } }, + location: { + getByPopularity: { useQuery: () => ({ data: state.byPopularity.data }) }, + }, + // Invoke the query-builder callback so the `t.location.getById(...)` map + // inside the component executes, returning one `{ data }` per location id. + useQueries: (cb: (t: unknown) => unknown) => + cb({ + location: { + getById: (input: { id: string }) => ({ data: { id: input.id } }), + }, + }), + }, +})); + +vi.mock("~/app/_components/shared/star-graph", () => ({ + default: () =>
, +})); +vi.mock("~/app/_components/reviews/review-card", () => ({ + ReviewCard: ({ reviewObj }: { reviewObj: { id: string } }) => ( +
{reviewObj.id}
+ ), +})); +vi.mock("~/utils/reviewCountByStars", () => ({ + calculateRatings: () => [], +})); +vi.mock("~/utils/locationHelpers", () => ({ + prettyLocationName: (loc: { id: string }) => `loc-${loc.id}`, +})); + +// Popover/DropdownMenu doubles that always render their children so the inner +// FilterPanelContent and Sort buttons are present and clickable in jsdom. +vi.mock("@cooper/ui/popover", () => ({ + Popover: ({ + children, + onOpenChange, + }: { + children: React.ReactNode; + onOpenChange?: (open: boolean) => void; + }) => ( +
+ + {children} +
+ ), + PopoverAnchor: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + PopoverContent: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), +})); +vi.mock("@cooper/ui/dropdown-menu", () => ({ + DropdownMenu: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + DropdownMenuTrigger: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + DropdownMenuContent: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + DropdownMenuLabel: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), +})); + +// DropdownFilter / FilterPanelContent doubles expose their callbacks as +// buttons keyed by title so tests can drive the component's filter state. +vi.mock("~/app/_components/filters/dropdown-filter", () => ({ + default: ({ + title, + onTriggerClick, + onSelectionChange, + onSearchChange, + }: { + title: string; + onTriggerClick?: () => void; + onSelectionChange?: (s: string[]) => void; + onSearchChange?: (s: string) => void; + }) => ( +
+ + + {onSearchChange && ( + + )} +
+ ), + FilterPanelContent: ({ + title, + onSelectionChange, + onSearchChange, + onClose, + }: { + title: string; + onSelectionChange?: (s: string[]) => void; + onSearchChange?: (s: string) => void; + onClose: () => void; + }) => ( +
+ + {onSearchChange && ( + + )} + +
+ ), +})); + +import { ReviewModal } from "~/app/_components/roles/modals/review-modal"; + +const twoReviews: Review[] = [ + { + id: "rev-1", + overallRating: 4, + workYear: 2024, + workTerm: "FALL", + locationId: "loc-a", + jobType: "CO-OP", + }, + { + id: "rev-2", + overallRating: 2, + workYear: 2023, + workTerm: "SPRING", + locationId: "loc-b", + jobType: "INTERNSHIP", + }, +]; + +describe("ReviewModal", () => { + beforeEach(() => { + state.reviews = { data: [], isSuccess: true }; + state.usersReviews = { data: [], isSuccess: true }; + state.list = { data: [] }; + state.byPopularity = { data: [] }; + }); + + describe("empty state", () => { + test("shows the empty state with an Add link when there are no reviews", () => { + render(); + expect(screen.getByText("Reviews")).toBeInTheDocument(); + expect(screen.getByText("No reviews yet")).toBeInTheDocument(); + expect(screen.getByText("Add one!")).toBeInTheDocument(); + }); + + test("hides the Add link once the user has 5 published reviews", () => { + state.usersReviews = { + data: Array.from({ length: 5 }, () => ({ status: "PUBLISHED" })), + isSuccess: true, + }; + render(); + expect(screen.getByText("No reviews yet")).toBeInTheDocument(); + expect(screen.queryByText("Add one!")).not.toBeInTheDocument(); + }); + }); + + describe("with reviews", () => { + beforeEach(() => { + state.reviews = { data: twoReviews, isSuccess: true }; + state.list = { data: [{ overallRating: 4 }, { overallRating: 5 }] }; + state.byPopularity = { data: [{ id: "loc-search" }] }; + }); + + test("renders the star graph and a card per review", () => { + render(); + expect(screen.getByTestId("star-graph")).toBeInTheDocument(); + expect(screen.getAllByTestId("review-card")).toHaveLength(2); + }); + + test("renders the filter triggers and Sort By control", () => { + render(); + expect(screen.getByTestId("trigger-Overall rating")).toBeInTheDocument(); + expect(screen.getByTestId("trigger-Location")).toBeInTheDocument(); + expect(screen.getByTestId("trigger-Job type")).toBeInTheDocument(); + expect(screen.getByText(/Sort By/)).toBeInTheDocument(); + }); + + test("opening the rating filter wraps it in an anchor and shows its panel", () => { + render(); + fireEvent.click(screen.getByTestId("trigger-Overall rating")); + expect(screen.getByTestId("popover-anchor")).toBeInTheDocument(); + expect(screen.getByTestId("panel-Overall rating")).toBeInTheDocument(); + }); + + test("selecting a rating range filters the review list", () => { + render(); + // ratingFilter [3,4] keeps rev-1 (4) and drops rev-2 (2). + fireEvent.click(screen.getByTestId("select-Overall rating")); + expect(screen.getAllByTestId("review-card")).toHaveLength(1); + expect(screen.getByText("rev-1")).toBeInTheDocument(); + }); + + test("opening the location filter drives selection and search", () => { + render(); + fireEvent.click(screen.getByTestId("trigger-Location")); + fireEvent.click(screen.getByTestId("panel-search-Location")); + fireEvent.click(screen.getByTestId("search-Location")); + // locationFilter ["3","4"] matches neither location, so no cards remain. + fireEvent.click(screen.getByTestId("select-Location")); + expect(screen.queryAllByTestId("review-card")).toHaveLength(0); + expect( + screen.getByText("No reviews found matching your filter criteria."), + ).toBeInTheDocument(); + }); + + test("opening the job type filter narrows by job type", () => { + render(); + fireEvent.click(screen.getByTestId("trigger-Job type")); + // jobTypeFilter becomes "3" (selected[0]); matches neither review. + fireEvent.click(screen.getByTestId("panel-select-Job type")); + expect(screen.getByTestId("panel-Job type")).toBeInTheDocument(); + }); + + test("closing a panel resets the open filter key", () => { + render(); + fireEvent.click(screen.getByTestId("trigger-Overall rating")); + expect(screen.getByTestId("panel-Overall rating")).toBeInTheDocument(); + fireEvent.click(screen.getByTestId("panel-close-Overall rating")); + expect( + screen.queryByTestId("panel-Overall rating"), + ).not.toBeInTheDocument(); + }); + + test("clicking the same trigger twice toggles the filter closed", () => { + render(); + fireEvent.click(screen.getByTestId("trigger-Overall rating")); + expect(screen.getByTestId("panel-Overall rating")).toBeInTheDocument(); + fireEvent.click(screen.getByTestId("trigger-Overall rating")); + expect( + screen.queryByTestId("panel-Overall rating"), + ).not.toBeInTheDocument(); + }); + + test("the popover onOpenChange(false) clears the open filter", () => { + render(); + fireEvent.click(screen.getByTestId("trigger-Location")); + expect(screen.getByTestId("panel-Location")).toBeInTheDocument(); + fireEvent.click(screen.getByTestId("popover-close")); + expect(screen.queryByTestId("panel-Location")).not.toBeInTheDocument(); + }); + + test("sorting by highest then lowest rating reorders the cards", () => { + render(); + fireEvent.click(screen.getByText("Highest rating")); + let cards = screen.getAllByTestId("review-card"); + expect(cards[0]).toHaveTextContent("rev-1"); + + fireEvent.click(screen.getByText("Lowest rating")); + cards = screen.getAllByTestId("review-card"); + expect(cards[0]).toHaveTextContent("rev-2"); + + fireEvent.click(screen.getByText("Most recent")); + cards = screen.getAllByTestId("review-card"); + expect(cards).toHaveLength(2); + }); + + test("most-recent sort falls back to term order within the same year", () => { + state.reviews = { + data: [ + { + id: "spring", + overallRating: 3, + workYear: 2024, + workTerm: "SPRING", + }, + { id: "fall", overallRating: 3, workYear: 2024, workTerm: "FALL" }, + ], + isSuccess: true, + }; + render(); + const cards = screen.getAllByTestId("review-card"); + // Same year → FALL (3) sorts before SPRING (1). + expect(cards[0]).toHaveTextContent("fall"); + expect(cards[1]).toHaveTextContent("spring"); + }); + }); +}); diff --git a/apps/web/test/components/review-section.test.tsx b/apps/web/test/components/review-section.test.tsx new file mode 100644 index 00000000..e645106a --- /dev/null +++ b/apps/web/test/components/review-section.test.tsx @@ -0,0 +1,97 @@ +import type { ReactNode } from "react"; +import { fireEvent, render, screen } from "@testing-library/react"; +import { FormProvider, useForm } from "react-hook-form"; +import { describe, expect, test, vi } from "vitest"; + +vi.mock("~/app/_components/filters/filter-body", () => ({ + default: ({ + title, + options, + onSelectionChange, + }: { + title: string; + options: { value: string; label: string }[]; + onSelectionChange: (selected: string[]) => void; + }) => ( +
+ {options.map((opt) => ( + + ))} +
+ ), +})); + +import { ReviewSection } from "~/app/_components/form/sections/review-section"; + +function Wrapper({ children }: { children: ReactNode }) { + const form = useForm({ + defaultValues: { overallRating: undefined, textReview: "" }, + }); + return {children}; +} + +describe("ReviewSection", () => { + test("renders the overall rating and review text labels", () => { + render( + + + , + ); + expect(screen.getByText("Overall rating")).toBeInTheDocument(); + expect(screen.getByText("Review text")).toBeInTheDocument(); + }); + + test("passes the 1-5 rating options to the filter body", () => { + render( + + + , + ); + expect(screen.getByTestId("filter-body")).toHaveAttribute( + "data-title", + "Overall rating", + ); + for (const n of [1, 2, 3, 4, 5]) { + expect(screen.getByText(`rating-${n}`)).toBeInTheDocument(); + } + }); + + test("renders the review text helper copy and textarea", () => { + render( + + + , + ); + expect( + screen.getByText(/This is your chance to share more details/), + ).toBeInTheDocument(); + expect( + screen.getByPlaceholderText(/job duties not mentioned/), + ).toBeInTheDocument(); + }); + + test("writes the selected rating back into the form", () => { + function Probe() { + const form = useForm({ + defaultValues: { overallRating: undefined, textReview: "" }, + }); + return ( + + + + {String(form.watch("overallRating"))} + + + ); + } + render(); + fireEvent.click(screen.getByText("rating-4")); + expect(screen.getByTestId("rating-value")).toHaveTextContent("4"); + }); +}); diff --git a/apps/web/test/components/review-view-edit-modal.test.tsx b/apps/web/test/components/review-view-edit-modal.test.tsx new file mode 100644 index 00000000..1bfdae9e --- /dev/null +++ b/apps/web/test/components/review-view-edit-modal.test.tsx @@ -0,0 +1,329 @@ +import { act, fireEvent, render, screen } from "@testing-library/react"; +import { beforeEach, describe, expect, test, vi } from "vitest"; + +interface Query { + data: T; + isLoading: boolean; +} + +const h = vi.hoisted(() => { + const make = (data: T, isLoading = false): Query => ({ + data, + isLoading, + }); + + const fullReview = () => ({ + id: "rev-1", + roleId: "role-1", + companyId: "company-1", + locationId: "loc-1", + status: "PUBLISHED", + workTerm: "FALL", + workYear: 2024, + overallRating: 4.5, + cultureRating: 3, + supervisorRating: 5, + jobType: "CO_OP", + workEnvironment: "REMOTE", + drugTest: false, + pto: true, + overtimeNormal: false, + federalHolidays: true, + freeLunch: true, + travelBenefits: false, + freeMerch: false, + snackBar: false, + otherBenefits: null, + hourlyPay: "25", + jobLength: 6, + workHours: 40, + accessibleByTransportation: true, + teamOutings: false, + coffeeChats: false, + constructiveFeedback: false, + onboarding: false, + workStructure: false, + careerGrowth: false, + reviewHeadline: "Great experience", + textReview: "This was a wonderful co-op opportunity overall.", + interviewRounds: [ + { + id: "ir-1", + interviewType: "TECHNICAL", + interviewDifficulty: "MEDIUM", + }, + ], + reviewsToTools: [{ tool: { name: "React" } }], + }); + + return { + make, + fullReview, + invalidate: vi.fn(), + toastSuccess: vi.fn(), + toastError: vi.fn(), + updateMutateAsync: vi.fn().mockResolvedValue({ id: "rev-1" }), + isPending: false, + reviewQuery: make | undefined>(undefined), + roleQuery: make<{ title: string } | undefined>({ + title: "Software Engineer", + }), + companyQuery: make<{ name: string } | undefined>({ name: "Acme Corp" }), + locationQuery: make | undefined>({ + city: "Boston", + state: "MA", + country: "USA", + }), + profileQuery: make<{ id: string } | undefined>({ id: "p1" }), + }; +}); + +vi.mock("@cooper/ui", () => ({ + useCustomToast: () => ({ + toast: { success: h.toastSuccess, error: h.toastError }, + }), +})); + +vi.mock("@cooper/ui/dialog", () => ({ + Dialog: ({ children, open }: { children: React.ReactNode; open: boolean }) => + open ?
{children}
: null, + DialogContent: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + DialogClose: ({ children }: { children: React.ReactNode }) => <>{children}, +})); + +vi.mock("@cooper/ui/form", () => ({ + Form: ({ children }: { children: React.ReactNode }) =>
{children}
, +})); + +vi.mock("~/app/_components/form/sections", () => ({ + BasicInfoSection: () =>
, + CompanyDetailsSection: () =>
, + InterviewSection: () =>
, + PaySection: () =>
, + ReviewSection: () =>
, +})); + +vi.mock("~/app/_components/reviews/delete-review-dialogue", () => ({ + DeleteReviewDialog: ({ trigger }: { trigger: React.ReactNode }) => ( +
{trigger}
+ ), +})); + +vi.mock("~/trpc/react", () => ({ + api: { + useUtils: () => ({ + review: { getByProfile: { invalidate: h.invalidate } }, + }), + review: { + getById: { useQuery: () => h.reviewQuery }, + update: { + useMutation: () => ({ + mutateAsync: h.updateMutateAsync, + isPending: h.isPending, + }), + }, + }, + role: { getById: { useQuery: () => h.roleQuery } }, + company: { getById: { useQuery: () => h.companyQuery } }, + location: { getById: { useQuery: () => h.locationQuery } }, + profile: { getCurrentUser: { useQuery: () => h.profileQuery } }, + }, +})); + +import { ReviewViewEditModal } from "~/app/_components/reviews/review-view-edit-modal"; + +const noop = () => undefined; + +function renderModal( + props: Partial> = {}, +) { + return render( + , + ); +} + +describe("ReviewViewEditModal", () => { + beforeEach(() => { + vi.clearAllMocks(); + h.isPending = false; + h.reviewQuery = h.make(h.fullReview()); + h.roleQuery = h.make({ title: "Software Engineer" }); + h.companyQuery = h.make({ name: "Acme Corp" }); + h.locationQuery = h.make({ city: "Boston", state: "MA", country: "USA" }); + h.profileQuery = h.make({ id: "p1" }); + }); + + test("renders nothing when closed", () => { + renderModal({ open: false }); + expect(screen.queryByTestId("dialog")).not.toBeInTheDocument(); + }); + + test("shows a loading state while the review is loading", () => { + h.reviewQuery = h.make(undefined, true); + renderModal(); + expect(screen.getByText("Loading...")).toBeInTheDocument(); + }); + + describe("view mode", () => { + test("renders the role title and company in the header", () => { + renderModal(); + // Role title appears in the header and in the "Role title" field. + expect(screen.getAllByText("Software Engineer").length).toBeGreaterThan( + 0, + ); + expect(screen.getAllByText(/Acme Corp/).length).toBeGreaterThan(0); + }); + + test("renders all of the section headings", () => { + renderModal(); + expect(screen.getByText("Basic information")).toBeInTheDocument(); + expect(screen.getByText("On the job")).toBeInTheDocument(); + expect(screen.getByText("Pay")).toBeInTheDocument(); + expect(screen.getByText("Interview")).toBeInTheDocument(); + expect(screen.getByText("Review and rate")).toBeInTheDocument(); + }); + + test("maps work term and work environment codes to friendly labels", () => { + renderModal(); + expect(screen.getByText("Fall")).toBeInTheDocument(); + expect(screen.getByText("Remote")).toBeInTheDocument(); + }); + + test("renders the formatted hourly pay and overall rating", () => { + renderModal(); + expect(screen.getByText("$25.00 / hour")).toBeInTheDocument(); + expect(screen.getByText("4.5")).toBeInTheDocument(); + }); + + test("renders the review text", () => { + renderModal(); + expect( + screen.getByText("This was a wonderful co-op opportunity overall."), + ).toBeInTheDocument(); + }); + + test("renders only the active benefits", () => { + renderModal(); + expect(screen.getByText("Federal holidays off")).toBeInTheDocument(); + expect(screen.getByText("Free lunch")).toBeInTheDocument(); + expect(screen.queryByText("Snack bar")).not.toBeInTheDocument(); + }); + + test("renders interview rounds with type and difficulty", () => { + renderModal(); + expect(screen.getByText("Round 1")).toBeInTheDocument(); + expect(screen.getByText("TECHNICAL")).toBeInTheDocument(); + expect(screen.getByText("MEDIUM")).toBeInTheDocument(); + }); + + test("shows the empty interview message when there are no rounds", () => { + h.reviewQuery = h.make({ ...h.fullReview(), interviewRounds: [] }); + renderModal(); + expect( + screen.getByText("No interview rounds recorded."), + ).toBeInTheDocument(); + }); + + test("renders the Delete and Edit buttons", () => { + renderModal(); + expect(screen.getByTestId("delete-review-dialog")).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Edit Review" }), + ).toBeInTheDocument(); + }); + + test("clicking Edit Review switches to edit mode", () => { + const onModeChange = vi.fn(); + renderModal({ onModeChange }); + fireEvent.click(screen.getByRole("button", { name: "Edit Review" })); + expect(onModeChange).toHaveBeenCalledWith("edit"); + }); + }); + + describe("edit mode", () => { + test("renders the Edit Review heading and all form sections", () => { + renderModal({ mode: "edit" }); + expect(screen.getByText("Edit Review")).toBeInTheDocument(); + expect(screen.getByTestId("basic-info-section")).toBeInTheDocument(); + expect(screen.getByTestId("company-details-section")).toBeInTheDocument(); + expect(screen.getByTestId("pay-section")).toBeInTheDocument(); + expect(screen.getByTestId("interview-section")).toBeInTheDocument(); + expect(screen.getByTestId("review-section")).toBeInTheDocument(); + }); + + test("renders Discard and Save buttons but no Submit for a published review", () => { + renderModal({ mode: "edit" }); + expect( + screen.getByRole("button", { name: "Discard edits" }), + ).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Save edits" }), + ).toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: "Submit review" }), + ).not.toBeInTheDocument(); + }); + + test("shows the Submit button for a draft review", () => { + h.reviewQuery = h.make({ ...h.fullReview(), status: "DRAFT" }); + renderModal({ mode: "edit" }); + expect( + screen.getByRole("button", { name: "Submit review" }), + ).toBeInTheDocument(); + }); + + test("shows pending labels and disables actions while saving", () => { + h.isPending = true; + h.reviewQuery = h.make({ ...h.fullReview(), status: "DRAFT" }); + renderModal({ mode: "edit" }); + expect(screen.getByRole("button", { name: "Saving..." })).toBeDisabled(); + expect( + screen.getByRole("button", { name: "Submitting..." }), + ).toBeDisabled(); + }); + + test("Save edits calls the update mutation and toasts success", async () => { + renderModal({ mode: "edit" }); + await act(async () => { + fireEvent.click(screen.getByRole("button", { name: "Save edits" })); + }); + expect(h.updateMutateAsync).toHaveBeenCalledTimes(1); + expect(h.updateMutateAsync).toHaveBeenCalledWith( + expect.objectContaining({ id: "rev-1", status: "PUBLISHED" }), + ); + expect(h.toastSuccess).toHaveBeenCalledWith("Review saved."); + }); + + test("clicking Discard edits does not call the update mutation", () => { + renderModal({ mode: "edit" }); + fireEvent.click(screen.getByRole("button", { name: "Discard edits" })); + expect(h.updateMutateAsync).not.toHaveBeenCalled(); + }); + + test("Submit review blocks and toasts when the form is invalid", async () => { + h.reviewQuery = h.make({ + ...h.fullReview(), + status: "DRAFT", + textReview: "", + workTerm: null, + }); + renderModal({ mode: "edit" }); + await act(async () => { + fireEvent.click(screen.getByRole("button", { name: "Submit review" })); + }); + expect(h.toastError).toHaveBeenCalledWith( + "Please fill in all required fields.", + ); + expect(h.updateMutateAsync).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/apps/web/test/components/reviews-bar-graph.test.tsx b/apps/web/test/components/reviews-bar-graph.test.tsx new file mode 100644 index 00000000..c386bfb1 --- /dev/null +++ b/apps/web/test/components/reviews-bar-graph.test.tsx @@ -0,0 +1,32 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, test } from "vitest"; + +import BarGraph from "~/app/_components/reviews/bar-graph"; + +describe("reviews/BarGraph", () => { + test("renders the title and value to two significant figures", () => { + render(); + expect(screen.getByText("Culture")).toBeInTheDocument(); + expect(screen.getByText("4.0")).toBeInTheDocument(); + }); + + test("does not render the industry average label when absent", () => { + render(); + expect(screen.queryByText(/Industry average/)).not.toBeInTheDocument(); + }); + + test("renders the industry average label when provided", () => { + render( + , + ); + expect(screen.getByText("Industry average: 3.5")).toBeInTheDocument(); + }); + + test("caps the fill width at 100% when the value exceeds the max", () => { + const { container } = render( + , + ); + const fill = container.querySelector(".bg-cooper-blue-600"); + expect(fill).toHaveStyle({ width: "100%" }); + }); +}); diff --git a/apps/web/test/components/role-card-preview.test.tsx b/apps/web/test/components/role-card-preview.test.tsx new file mode 100644 index 00000000..deaf0324 --- /dev/null +++ b/apps/web/test/components/role-card-preview.test.tsx @@ -0,0 +1,98 @@ +import { render, screen } from "@testing-library/react"; +import { beforeEach, describe, expect, test, vi } from "vitest"; + +const state: { + company?: { name: string }; + role?: { id: string; title: string }; + reviews: { data: unknown[]; isSuccess: boolean }; + averages?: { averageOverallRating: number }; + location: { isSuccess: boolean; data?: unknown }; + compareMode: boolean; +} = { + reviews: { data: [], isSuccess: false }, + location: { isSuccess: false }, + compareMode: false, +}; + +vi.mock("next/image", () => ({ + default: ({ alt }: { alt: string }) => {alt}, +})); +vi.mock("~/trpc/react", () => ({ + api: { + company: { getById: { useQuery: () => ({ data: state.company }) } }, + role: { + getById: { useQuery: () => ({ data: state.role }) }, + getAverageById: { useQuery: () => ({ data: state.averages }) }, + }, + review: { + getByRole: { + useQuery: () => ({ + data: state.reviews.data, + isSuccess: state.reviews.isSuccess, + }), + }, + }, + location: { getById: { useQuery: () => state.location } }, + }, +})); +vi.mock("~/app/_components/shared/favorite-button", () => ({ + FavoriteButton: () =>
, +})); +vi.mock("~/app/_components/compare/compare-context", () => ({ + useCompare: () => ({ + isCompareMode: state.compareMode, + comparedRoleIds: [], + enterCompareMode: vi.fn(), + addRoleId: vi.fn(), + }), +})); + +import { RoleCardPreview } from "~/app/_components/roles/role-card-preview"; + +const roleObj = { id: "r1", companyId: "c1" } as never; + +describe("RoleCardPreview", () => { + beforeEach(() => { + state.company = { name: "Acme" }; + state.role = { id: "r1", title: "Software Engineer" }; + state.reviews = { data: [{ locationId: "l1" }], isSuccess: true }; + state.averages = { averageOverallRating: 4.2 }; + state.location = { + isSuccess: true, + data: { city: "Boston", state: "MA", country: "USA" }, + }; + state.compareMode = false; + }); + + test("renders the role title and company name", () => { + render(); + expect(screen.getByText("Software Engineer")).toBeInTheDocument(); + expect(screen.getByText(/Acme/)).toBeInTheDocument(); + }); + + test("renders the rounded average rating and review count", () => { + render(); + expect(screen.getByText("4.2")).toBeInTheDocument(); + expect(screen.getByText("(1 review)")).toBeInTheDocument(); + }); + + test("pluralizes the review count for multiple reviews", () => { + state.reviews = { + data: [{ locationId: "l1" }, { locationId: "l2" }], + isSuccess: true, + }; + render(); + expect(screen.getByText("(2 reviews)")).toBeInTheDocument(); + }); + + test("shows the favorite button outside compare mode", () => { + render(); + expect(screen.getByTestId("favorite-button")).toBeInTheDocument(); + }); + + test("hides the favorite button in compare mode", () => { + state.compareMode = true; + render(); + expect(screen.queryByTestId("favorite-button")).not.toBeInTheDocument(); + }); +}); diff --git a/apps/web/test/components/role-info.test.tsx b/apps/web/test/components/role-info.test.tsx new file mode 100644 index 00000000..05cae43c --- /dev/null +++ b/apps/web/test/components/role-info.test.tsx @@ -0,0 +1,133 @@ +import { render, screen } from "@testing-library/react"; +import { beforeEach, describe, expect, test, vi } from "vitest"; + +const state: { + reviews: { data: unknown[]; isSuccess: boolean }; + company?: { id: string; name: string; industry: string }; + averages?: { averageOverallRating: number }; + location: { isSuccess: boolean; data?: unknown }; + compareMode: boolean; +} = { + reviews: { data: [], isSuccess: false }, + location: { isSuccess: false }, + compareMode: false, +}; + +vi.mock("next/image", () => ({ + default: ({ alt }: { alt: string }) => {alt}, +})); +vi.mock("~/trpc/react", () => { + const q = (data: unknown) => ({ useQuery: () => ({ data }) }); + return { + api: { + review: { + getByRole: { + useQuery: () => ({ + data: state.reviews.data, + isSuccess: state.reviews.isSuccess, + }), + }, + getInterviewDataByIndustry: q(undefined), + getInterviewDataGlobal: q(undefined), + getPayDataGlobal: q(undefined), + getPayDataByIndustry: q(undefined), + getByCompany: q([]), + }, + location: { getById: { useQuery: () => state.location } }, + company: { getById: { useQuery: () => ({ data: state.company }) } }, + role: { + getAverageById: { useQuery: () => ({ data: state.averages }) }, + getInterviewDataById: q(undefined), + }, + useQueries: () => [], + }, + }; +}); +vi.mock("~/app/_components/compare/compare-context", () => ({ + useCompare: () => ({ + isCompareMode: state.compareMode, + comparedRoleIds: [], + isDragging: false, + removeRoleId: vi.fn(), + }), +})); +vi.mock("~/app/_components/shared/favorite-button", () => ({ + FavoriteButton: () =>
, +})); +vi.mock("~/app/_components/shared/report-button", () => ({ + ReportButton: () =>
, +})); +vi.mock("~/app/_components/companies/company-popup", () => ({ + CompanyPopup: ({ trigger }: { trigger: React.ReactNode }) => ( +
{trigger}
+ ), +})); +vi.mock("~/app/_components/companies/company-card-preview", () => ({ + CompanyCardPreview: ({ companyObj }: { companyObj: { name: string } }) => ( +
{companyObj.name}
+ ), +})); +vi.mock("~/app/_components/roles/modals/interview-modal", () => ({ + InterviewModal: () =>
, +})); +vi.mock("~/app/_components/roles/modals/on-the-job-modal", () => ({ + OnTheJobModal: () =>
, +})); +vi.mock("~/app/_components/roles/modals/pay-modal", () => ({ + PayModal: () =>
, +})); +vi.mock("~/app/_components/roles/modals/review-modal", () => ({ + ReviewModal: () =>
, +})); + +import { RoleInfo } from "~/app/_components/roles/role-info"; + +const roleObj = { + id: "r1", + companyId: "c1", + title: "Software Engineer", +} as never; + +describe("RoleInfo", () => { + beforeEach(() => { + state.reviews = { data: [{ locationId: "l1" }], isSuccess: true }; + state.company = { id: "c1", name: "Acme", industry: "TECHNOLOGY" }; + state.averages = { averageOverallRating: 4.2 }; + state.location = { + isSuccess: true, + data: { city: "Boston", state: "MA", country: "USA" }, + }; + state.compareMode = false; + }); + + test("renders the role title and favorite/report controls", () => { + render(); + expect(screen.getByText("Software Engineer")).toBeInTheDocument(); + expect(screen.getByTestId("favorite-button")).toBeInTheDocument(); + expect(screen.getByTestId("report-button")).toBeInTheDocument(); + }); + + test("renders the on-the-job, interview, pay, and review modals", () => { + render(); + // Each section renders a mobile and a desktop variant. + expect(screen.getAllByTestId("on-the-job-modal").length).toBeGreaterThan(0); + expect(screen.getAllByTestId("interview-modal").length).toBeGreaterThan(0); + expect(screen.getAllByTestId("pay-modal").length).toBeGreaterThan(0); + expect(screen.getAllByTestId("review-modal").length).toBeGreaterThan(0); + }); + + test("renders the average rating and review count", () => { + render(); + expect(screen.getByText("4.2")).toBeInTheDocument(); + expect(screen.getByText(/1 review/)).toBeInTheDocument(); + }); + + test("renders a back control when onBack is provided", () => { + const onBack = vi.fn(); + const { container } = render( + , + ); + // The back affordance is an inline svg with an onClick handler. + expect(container.querySelector("svg")).toBeInTheDocument(); + }); +}); diff --git a/apps/web/test/components/role-page.test.tsx b/apps/web/test/components/role-page.test.tsx new file mode 100644 index 00000000..93d30b04 --- /dev/null +++ b/apps/web/test/components/role-page.test.tsx @@ -0,0 +1,66 @@ +import { render, screen } from "@testing-library/react"; +import { beforeEach, describe, expect, test, vi } from "vitest"; + +let roleQuery: { + isSuccess: boolean; + isPending: boolean; + data?: { title: string }; +}; +let idParam: string | null = "role-1"; + +vi.mock("next/navigation", () => ({ + useSearchParams: () => ({ get: () => idParam }), +})); + +vi.mock("~/trpc/react", () => ({ + api: { + role: { + getById: { useQuery: () => roleQuery }, + }, + }, +})); + +vi.mock("~/app/_components/roles/role-info", () => ({ + RoleInfo: ({ roleObj }: { roleObj: { title: string } }) => ( +
{roleObj.title}
+ ), +})); +vi.mock("~/app/_components/loading-results", () => ({ + default: () =>
, +})); +vi.mock("~/app/_components/no-results", () => ({ + default: () =>
, +})); + +import Role from "~/app/(pages)/(dashboard)/roles/role/page"; + +describe("Role page", () => { + beforeEach(() => { + idParam = "role-1"; + roleQuery = { isSuccess: false, isPending: true }; + }); + + test("renders the loading state while pending", () => { + roleQuery = { isSuccess: false, isPending: true }; + render(); + expect(screen.getByTestId("loading")).toBeInTheDocument(); + }); + + test("renders the role info on success", () => { + roleQuery = { + isSuccess: true, + isPending: false, + data: { title: "Software Engineer" }, + }; + render(); + expect(screen.getByTestId("role-info")).toHaveTextContent( + "Software Engineer", + ); + }); + + test("renders no-results when the query settles without success", () => { + roleQuery = { isSuccess: false, isPending: false }; + render(); + expect(screen.getByTestId("no-results")).toBeInTheDocument(); + }); +}); diff --git a/apps/web/test/components/role-type-selector.test.tsx b/apps/web/test/components/role-type-selector.test.tsx new file mode 100644 index 00000000..3b4a9fc0 --- /dev/null +++ b/apps/web/test/components/role-type-selector.test.tsx @@ -0,0 +1,54 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, test, vi } from "vitest"; + +import RoleTypeSelector from "~/app/_components/filters/role-type-selector"; + +function setup(selectedType: "roles" | "companies" | "all" = "all") { + const onSelectedTypeChange = vi.fn(); + render( + , + ); + return { onSelectedTypeChange }; +} + +describe("RoleTypeSelector", () => { + test("renders all three chips", () => { + setup(); + expect(screen.getByText("All")).toBeInTheDocument(); + expect(screen.getByText("Jobs")).toBeInTheDocument(); + expect(screen.getByText("Companies")).toBeInTheDocument(); + }); + + test("clicking All selects all", () => { + const { onSelectedTypeChange } = setup("roles"); + fireEvent.click(screen.getByText("All")); + expect(onSelectedTypeChange).toHaveBeenCalledWith("all"); + }); + + test("clicking Jobs from all selects roles", () => { + const { onSelectedTypeChange } = setup("all"); + fireEvent.click(screen.getByText("Jobs")); + expect(onSelectedTypeChange).toHaveBeenCalledWith("roles"); + }); + + test("clicking Jobs when already roles toggles back to all", () => { + const { onSelectedTypeChange } = setup("roles"); + fireEvent.click(screen.getByText("Jobs")); + expect(onSelectedTypeChange).toHaveBeenCalledWith("all"); + }); + + test("clicking Companies from all selects companies", () => { + const { onSelectedTypeChange } = setup("all"); + fireEvent.click(screen.getByText("Companies")); + expect(onSelectedTypeChange).toHaveBeenCalledWith("companies"); + }); + + test("clicking Companies when already companies toggles back to all", () => { + const { onSelectedTypeChange } = setup("companies"); + fireEvent.click(screen.getByText("Companies")); + expect(onSelectedTypeChange).toHaveBeenCalledWith("all"); + }); +}); diff --git a/apps/web/test/components/roles-page.test.tsx b/apps/web/test/components/roles-page.test.tsx new file mode 100644 index 00000000..e2535766 --- /dev/null +++ b/apps/web/test/components/roles-page.test.tsx @@ -0,0 +1,282 @@ +import { render, screen } from "@testing-library/react"; +import { beforeEach, describe, expect, test, vi } from "vitest"; + +const h = vi.hoisted(() => { + const emptyQuery = { + isSuccess: false, + isPending: false, + isError: false, + data: undefined as unknown, + }; + return { + emptyQuery, + listQuery: { ...emptyQuery }, + pageNumberQuery: { + isSuccess: false, + isError: false, + data: undefined, + }, + push: vi.fn(), + searchParams: new URLSearchParams(), + compare: { + isCompareMode: false, + anchorRoleId: null as string | null, + comparedRoleIds: [] as string[], + exitCompareMode: vi.fn(), + }, + }; +}); + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push: h.push }), + useSearchParams: () => h.searchParams, +})); + +vi.mock("next/image", () => ({ + default: ({ alt }: { alt: string }) => {alt}, +})); + +vi.mock("@cooper/ui", () => ({ + cn: (...args: unknown[]) => args.flat().filter(Boolean).join(" "), + Pagination: ({ + currentPage, + totalPages, + }: { + currentPage: number; + totalPages: number; + }) => ( +
+ {currentPage}/{totalPages} +
+ ), +})); + +vi.mock("@cooper/ui/button", () => ({ + Button: ({ + children, + onClick, + }: { + children: React.ReactNode; + onClick?: () => void; + }) => ( + + ), +})); + +vi.mock("@cooper/ui/dropdown-menu", () => ({ + DropdownMenu: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + DropdownMenuContent: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + DropdownMenuLabel: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + DropdownMenuTrigger: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), +})); + +vi.mock("~/app/_components/compare/compare-context", () => ({ + useCompare: () => h.compare, +})); + +vi.mock("~/app/_components/compare/compare-ui", () => ({ + CompareColumns: ({ anchorRole }: { anchorRole: { id: string } }) => ( +
{anchorRole.id}
+ ), + CompareControls: ({ anchorRoleId }: { anchorRoleId: string }) => ( +
{anchorRoleId}
+ ), +})); + +vi.mock("~/app/_components/companies/company-card-preview", () => ({ + CompanyCardPreview: ({ companyObj }: { companyObj: { name: string } }) => ( +
{companyObj.name}
+ ), +})); +vi.mock("~/app/_components/companies/company-info", () => ({ + default: ({ companyObj }: { companyObj: { name: string } }) => ( +
{companyObj.name}
+ ), +})); +vi.mock("~/app/_components/filters/dropdown-filters-bar", () => ({ + default: () =>
, +})); +vi.mock("~/app/_components/filters/role-type-selector", () => ({ + default: ({ selectedType }: { selectedType: string }) => ( +
{selectedType}
+ ), +})); +vi.mock("~/app/_components/filters/sidebar-filter", () => ({ + default: ({ isOpen }: { isOpen: boolean }) => ( +
{isOpen ? "open" : "closed"}
+ ), +})); +vi.mock("~/app/_components/loading-results", () => ({ + default: () =>
, +})); +vi.mock("~/app/_components/no-results", () => ({ + default: () =>
, +})); +vi.mock("~/app/_components/roles/role-card-preview", () => ({ + RoleCardPreview: ({ roleObj }: { roleObj: { title: string } }) => ( +
{roleObj.title}
+ ), +})); +vi.mock("~/app/_components/roles/role-info", () => ({ + RoleInfo: ({ roleObj }: { roleObj: { title: string } }) => ( +
{roleObj.title}
+ ), +})); +vi.mock("~/app/_components/search/search-filter", () => ({ + default: () =>
, +})); + +vi.mock("~/trpc/react", () => { + const empty = () => ({ useQuery: () => h.emptyQuery }); + return { + api: { + company: { getBySlug: empty() }, + role: { + getByCompanySlugAndRoleSlug: empty(), + getByIdWithCompany: empty(), + }, + roleAndCompany: { + getPageNumber: { useQuery: () => h.pageNumberQuery }, + list: { useQuery: () => h.listQuery }, + }, + }, + }; +}); + +import Roles from "~/app/(pages)/(dashboard)/roles/page"; + +const roleItem = { + id: "r1", + type: "role" as const, + title: "Software Engineer", +}; +const companyItem = { + id: "c1", + type: "company" as const, + name: "Acme Corp", +}; + +function setList(data: unknown) { + h.listQuery = { + isSuccess: true, + isPending: false, + isError: false, + data, + }; +} + +describe("Roles page", () => { + beforeEach(() => { + vi.clearAllMocks(); + h.searchParams = new URLSearchParams(); + h.listQuery = { + isSuccess: false, + isPending: true, + isError: false, + data: undefined, + }; + h.pageNumberQuery = { isSuccess: false, isError: false, data: undefined }; + h.compare = { + isCompareMode: false, + anchorRoleId: null, + comparedRoleIds: [], + exitCompareMode: vi.fn(), + }; + }); + + test("renders the search bar and sidebar filter chrome", () => { + render(); + expect(screen.getByTestId("search-filter")).toBeInTheDocument(); + expect(screen.getByTestId("sidebar-filter")).toHaveTextContent("closed"); + }); + + test("shows the loading state while the list query is pending", () => { + render(); + expect(screen.getByTestId("loading-results")).toBeInTheDocument(); + }); + + test("shows no-results when the query succeeds with an empty list", () => { + setList({ + items: [], + totalCount: 0, + totalRolesCount: 0, + totalCompanyCount: 0, + }); + render(); + expect(screen.getByTestId("no-results")).toBeInTheDocument(); + }); + + test("renders role and company cards with pagination", () => { + setList({ + items: [roleItem, companyItem], + totalCount: 12, + totalRolesCount: 6, + totalCompanyCount: 6, + }); + render(); + + expect(screen.getByTestId("role-card")).toHaveTextContent( + "Software Engineer", + ); + expect(screen.getByTestId("company-card")).toHaveTextContent("Acme Corp"); + // ceil(12 / 10) = 2 pages + expect(screen.getByTestId("pagination")).toHaveTextContent("1/2"); + expect(screen.getByTestId("role-type-selector")).toBeInTheDocument(); + }); + + test("auto-selects the first item and shows its detail pane", () => { + setList({ + items: [roleItem, companyItem], + totalCount: 2, + totalRolesCount: 1, + totalCompanyCount: 1, + }); + render(); + // First item is a role, so RoleInfo is shown in the detail pane. + expect(screen.getByTestId("role-info")).toHaveTextContent( + "Software Engineer", + ); + }); + + test("reflects the type query param in the role type selector", () => { + h.searchParams = new URLSearchParams("type=companies"); + setList({ + items: [companyItem], + totalCount: 1, + totalRolesCount: 0, + totalCompanyCount: 1, + }); + render(); + expect(screen.getByTestId("role-type-selector")).toHaveTextContent( + "companies", + ); + }); + + test("renders compare controls and columns in compare mode", () => { + h.compare = { + isCompareMode: true, + anchorRoleId: "r1", + comparedRoleIds: [], + exitCompareMode: vi.fn(), + }; + setList({ + items: [roleItem, companyItem], + totalCount: 2, + totalRolesCount: 1, + totalCompanyCount: 1, + }); + render(); + expect(screen.getByTestId("compare-controls")).toHaveTextContent("r1"); + expect(screen.getByTestId("compare-columns")).toHaveTextContent("r1"); + }); +}); diff --git a/apps/web/test/components/round-bar-graph.test.tsx b/apps/web/test/components/round-bar-graph.test.tsx new file mode 100644 index 00000000..943404e6 --- /dev/null +++ b/apps/web/test/components/round-bar-graph.test.tsx @@ -0,0 +1,45 @@ +import { render } from "@testing-library/react"; +import { describe, expect, test } from "vitest"; + +import RoundBarGraph from "~/app/_components/reviews/round-bar-graph"; + +function getFill(container: HTMLElement) { + return container.querySelector(".bg-cooper-blue-600")!; +} + +describe("RoundBarGraph", () => { + test("fills proportionally to the high/low span over the range", () => { + // span = 5-0 = 5 of range 10 -> 50% + const { container } = render(); + const fill = getFill(container); + expect(fill.style.width).toBe("50%"); + expect(fill.style.left).toBe("0%"); + }); + + test("offsets the fill by the low value", () => { + // low=2 of range 10 -> left 20%; span (8-2)=6 -> 60% + const { container } = render( + , + ); + const fill = getFill(container); + expect(fill.style.left).toBe("20%"); + expect(fill.style.width).toBe("60%"); + }); + + test("clamps the fill width at 100%", () => { + const { container } = render(); + expect(getFill(container).style.width).toBe("100%"); + }); + + test("renders dashed markers for industry averages", () => { + const { container } = render( + , + ); + expect(container.querySelectorAll(".border-dashed")).toHaveLength(2); + }); +}); diff --git a/apps/web/test/components/screen-size-indicator.test.tsx b/apps/web/test/components/screen-size-indicator.test.tsx new file mode 100644 index 00000000..74cf21fc --- /dev/null +++ b/apps/web/test/components/screen-size-indicator.test.tsx @@ -0,0 +1,38 @@ +import { render, screen } from "@testing-library/react"; +import { beforeEach, describe, expect, test, vi } from "vitest"; + +import { ScreenSizeIndicator } from "~/app/_components/screen-size-indicator"; + +// jsdom has no matchMedia; stub it so the effect can run. +function mockMatchMedia(matchingQuery: string | null) { + window.matchMedia = vi.fn().mockImplementation((query: string) => ({ + matches: query === matchingQuery, + media: query, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + })); +} + +describe("ScreenSizeIndicator", () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + test("shows the largest matching breakpoint", () => { + mockMatchMedia("(min-width: 1536px)"); + render(); + expect(screen.getByText(/Screen:/).textContent).toContain("2xl"); + }); + + test("falls back to xs when nothing matches", () => { + mockMatchMedia(null); + render(); + expect(screen.getByText(/Screen:/).textContent).toContain("xs"); + }); + + test("reports md for a mid-size breakpoint", () => { + mockMatchMedia("(min-width: 768px)"); + render(); + expect(screen.getByText(/Screen:/).textContent).toContain("md"); + }); +}); diff --git a/apps/web/test/components/search-filter.test.tsx b/apps/web/test/components/search-filter.test.tsx new file mode 100644 index 00000000..791a4deb --- /dev/null +++ b/apps/web/test/components/search-filter.test.tsx @@ -0,0 +1,56 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, test, vi } from "vitest"; + +const push = vi.fn(); +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push }), + usePathname: () => "/roles", +})); + +import SearchFilter from "~/app/_components/search/search-filter"; + +describe("SearchFilter", () => { + beforeEach(() => { + vi.clearAllMocks(); + window.history.pushState({}, "", "/roles"); + }); + + test("renders the embedded search bar", () => { + render(); + expect( + screen.getByPlaceholderText("Search for a job, company, industry..."), + ).toBeInTheDocument(); + }); + + test("applies the passed className to the form", () => { + const { container } = render(); + expect(container.querySelector("form")).toHaveClass("my-search"); + }); + + test("submitting pushes the pathname with the search query param", async () => { + const { container } = render(); + const input = screen.getByPlaceholderText( + "Search for a job, company, industry...", + ); + fireEvent.change(input, { target: { value: "nvidia" } }); + fireEvent.submit(container.querySelector("form")!); + + await waitFor(() => + expect(push).toHaveBeenCalledWith("/roles?search=nvidia"), + ); + }); + + test("clears stale company and role params on submit", async () => { + window.history.pushState({}, "", "/roles?company=abc&role=xyz"); + const { container } = render(); + const input = screen.getByPlaceholderText( + "Search for a job, company, industry...", + ); + fireEvent.change(input, { target: { value: "apple" } }); + fireEvent.submit(container.querySelector("form")!); + + await waitFor(() => + expect(push).toHaveBeenCalledWith("/roles?search=apple"), + ); + }); +}); diff --git a/apps/web/test/components/shared-round-bar-graph.test.tsx b/apps/web/test/components/shared-round-bar-graph.test.tsx new file mode 100644 index 00000000..bb986b5a --- /dev/null +++ b/apps/web/test/components/shared-round-bar-graph.test.tsx @@ -0,0 +1,43 @@ +import { render } from "@testing-library/react"; +import { describe, expect, test } from "vitest"; + +import RoundBarGraph from "~/app/_components/shared/round-bar-graph"; + +function getFill(container: HTMLElement) { + return container.querySelector(".bg-cooper-blue-600")!; +} + +describe("shared/RoundBarGraph", () => { + test("fills proportionally to the high/low span over the range", () => { + const { container } = render(); + const fill = getFill(container); + expect(fill.style.width).toBe("50%"); + expect(fill.style.left).toBe("0%"); + }); + + test("offsets the fill by the low value", () => { + const { container } = render( + , + ); + const fill = getFill(container); + expect(fill.style.left).toBe("20%"); + expect(fill.style.width).toBe("60%"); + }); + + test("clamps the fill width at 100%", () => { + const { container } = render(); + expect(getFill(container).style.width).toBe("100%"); + }); + + test("renders dashed markers for industry averages", () => { + const { container } = render( + , + ); + expect(container.querySelectorAll(".border-dashed")).toHaveLength(2); + }); +}); diff --git a/apps/web/test/components/sidebar-filter.test.tsx b/apps/web/test/components/sidebar-filter.test.tsx new file mode 100644 index 00000000..4e3ad4f2 --- /dev/null +++ b/apps/web/test/components/sidebar-filter.test.tsx @@ -0,0 +1,248 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { beforeEach, describe, expect, test, vi } from "vitest"; + +import type { FilterState } from "~/app/_components/filters/types"; + +// Mutable container for the location-by-popularity query result so individual +// tests can drive the location options mapping. +const h = vi.hoisted(() => { + const locationQuery: { + data: { id: string; label: string }[] | undefined; + } = { data: undefined }; + return { locationQuery }; +}); + +vi.mock("~/trpc/react", () => ({ + api: { + location: { + getByPopularity: { useQuery: () => h.locationQuery }, + }, + }, +})); + +vi.mock("@cooper/ui", () => ({ + cn: (...args: unknown[]) => args.flat().filter(Boolean).join(" "), +})); + +vi.mock("@cooper/ui/button", () => ({ + Button: ({ + children, + onClick, + }: { + children: React.ReactNode; + onClick?: () => void; + }) => , +})); + +vi.mock("~/utils/locationHelpers", () => ({ + prettyLocationName: (loc: { id: string; label?: string }) => + loc.label ?? loc.id, +})); + +vi.mock("~/app/_components/filters/role-type-selector", () => ({ + default: ({ + selectedType, + onSelectedTypeChange, + }: { + selectedType: string; + onSelectedTypeChange: (t: "roles" | "companies" | "all") => void; + }) => ( + + ), +})); + +// Lightweight SidebarSection mock that surfaces its props as buttons so the +// container's callback wiring can be exercised without the real component. +vi.mock("~/app/_components/filters/sidebar-section", () => ({ + default: ({ + title, + options, + onSelectionChange, + onSearchChange, + }: { + title: string; + options: { id: string; label: string }[]; + onSelectionChange?: (s: string[]) => void; + onSearchChange?: (s: string) => void; + }) => ( +
+ {title} + + {options.map((o) => o.label).join(",")} + + + {onSearchChange && ( + + )} +
+ ), +})); + +import SidebarFilter from "~/app/_components/filters/sidebar-filter"; + +const emptyFilters: FilterState = { + industries: [], + locations: [], + jobTypes: [], + hourlyPay: { min: 0, max: 0 }, + ratings: [], + workModels: [], + overtimeWork: [], + companyCulture: [], +}; + +function renderFilter( + overrides: Partial[0]> = {}, +) { + const onFilterChange = vi.fn(); + const onClose = vi.fn(); + const onSelectedTypeChange = vi.fn(); + render( + , + ); + return { onFilterChange, onClose, onSelectedTypeChange }; +} + +beforeEach(() => { + h.locationQuery = { data: undefined }; +}); + +describe("SidebarFilter - rendering", () => { + test("renders the header and all filter sections", () => { + renderFilter(); + expect(screen.getByText("Filters")).toBeInTheDocument(); + expect(screen.getByTestId("section-Industry")).toBeInTheDocument(); + expect(screen.getByTestId("section-Location")).toBeInTheDocument(); + expect(screen.getByTestId("section-Job type")).toBeInTheDocument(); + expect(screen.getByTestId("section-Hourly pay")).toBeInTheDocument(); + expect(screen.getByTestId("section-Work model")).toBeInTheDocument(); + expect(screen.getByTestId("section-Overtime work")).toBeInTheDocument(); + expect(screen.getByTestId("section-Company Culture")).toBeInTheDocument(); + expect(screen.getByTestId("section-Overall rating")).toBeInTheDocument(); + }); + + test("sorts industry options alphabetically", () => { + renderFilter(); + const labels = screen.getByTestId("opts-Industry").textContent ?? ""; + expect(labels.startsWith("Aerospace")).toBe(true); + }); + + test("passes job type options through", () => { + renderFilter(); + expect(screen.getByTestId("opts-Job type").textContent).toBe( + "Co-op,Internship", + ); + }); +}); + +describe("SidebarFilter - filter changes", () => { + test("a section selection merges into existing filters", () => { + const { onFilterChange } = renderFilter(); + fireEvent.click(screen.getByTestId("select-Industry")); + expect(onFilterChange).toHaveBeenCalledWith({ + ...emptyFilters, + industries: ["picked"], + }); + }); + + test("Clear all resets every filter field", () => { + const { onFilterChange } = renderFilter({ + filters: { + ...emptyFilters, + industries: ["TECHNOLOGY"], + ratings: ["4"], + }, + }); + fireEvent.click(screen.getByText("Clear all")); + expect(onFilterChange).toHaveBeenCalledWith(emptyFilters); + }); + + test("On the job Clear resets only the on-the-job fields", () => { + const { onFilterChange } = renderFilter({ + filters: { + ...emptyFilters, + industries: ["TECHNOLOGY"], + workModels: ["REMOTE"], + overtimeWork: ["Yes"], + companyCulture: ["3"], + }, + }); + fireEvent.click(screen.getByText("Clear")); + expect(onFilterChange).toHaveBeenCalledWith({ + ...emptyFilters, + industries: ["TECHNOLOGY"], + workModels: [], + overtimeWork: [], + companyCulture: [], + }); + }); +}); + +describe("SidebarFilter - close & type controls", () => { + test("Show Results triggers onClose", () => { + const { onClose } = renderFilter(); + fireEvent.click(screen.getByText("Show Results")); + expect(onClose).toHaveBeenCalled(); + }); + + test("clicking the overlay triggers onClose", () => { + const { onClose } = renderFilter(); + // The outermost overlay (parent of the panel) carries the onClose handler; + // the panel itself stops propagation. + const panel = screen.getByText("Filters").closest("div.fixed")!; + fireEvent.click(panel.parentElement!); + expect(onClose).toHaveBeenCalled(); + }); + + test("role type selector forwards the new type", () => { + const { onSelectedTypeChange } = renderFilter(); + fireEvent.click(screen.getByTestId("role-type-selector")); + expect(onSelectedTypeChange).toHaveBeenCalledWith("companies"); + }); +}); + +describe("SidebarFilter - location search", () => { + test("maps location query results into options", () => { + h.locationQuery = { + data: [ + { id: "1", label: "Boston, MA" }, + { id: "2", label: "Austin, TX" }, + ], + }; + renderFilter(); + // Trigger the 3-char search so the query path is enabled. + fireEvent.click(screen.getByTestId("search-Location")); + expect(screen.getByTestId("opts-Location").textContent).toBe( + "Boston, MA,Austin, TX", + ); + }); + + test("location options are empty when the query has no data", () => { + renderFilter(); + fireEvent.click(screen.getByTestId("search-Location")); + expect(screen.getByTestId("opts-Location").textContent).toBe(""); + }); +}); diff --git a/apps/web/test/components/sidebar-section.test.tsx b/apps/web/test/components/sidebar-section.test.tsx new file mode 100644 index 00000000..5b778758 --- /dev/null +++ b/apps/web/test/components/sidebar-section.test.tsx @@ -0,0 +1,104 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, test, vi } from "vitest"; + +vi.mock("@cooper/ui/autocomplete", () => ({ + default: ({ placeholder }: { placeholder?: string }) => ( + + ), +})); + +import SidebarSection from "~/app/_components/filters/sidebar-section"; + +const options = [ + { id: "a", label: "Apple", value: "apple" }, + { id: "b", label: "Banana", value: "banana" }, +]; + +describe("SidebarSection", () => { + test("renders title and Clear button in main variant", () => { + render( + , + ); + expect(screen.getByText("Fruit")).toBeInTheDocument(); + expect(screen.getByText("Clear")).toBeInTheDocument(); + // default checkbox variant shows options + expect(screen.getByText("Apple")).toBeInTheDocument(); + }); + + test("does not render Clear button in subsection variant", () => { + render( + , + ); + expect(screen.getByText("Sub")).toBeInTheDocument(); + expect(screen.queryByText("Clear")).toBeNull(); + }); + + test("Clear calls onSelectionChange with empty array for checkbox", () => { + const onSelectionChange = vi.fn(); + render( + , + ); + fireEvent.click(screen.getByText("Clear")); + expect(onSelectionChange).toHaveBeenCalledWith([]); + }); + + test("Clear calls onRangeChange(0,0) for range filterType", () => { + const onRangeChange = vi.fn(); + render( + , + ); + fireEvent.click(screen.getByText("Clear")); + expect(onRangeChange).toHaveBeenCalledWith(0, 0); + }); + + test("renders rating variant", () => { + render( + , + ); + expect(screen.getByText("1.0")).toBeInTheDocument(); + }); + + test("renders autocomplete variant via FilterBody", () => { + render( + , + ); + expect(screen.getByLabelText("autocomplete-search")).toBeInTheDocument(); + }); +}); diff --git a/apps/web/test/components/simple-search-bar.test.tsx b/apps/web/test/components/simple-search-bar.test.tsx new file mode 100644 index 00000000..244d786e --- /dev/null +++ b/apps/web/test/components/simple-search-bar.test.tsx @@ -0,0 +1,37 @@ +import type { ReactNode } from "react"; +import { fireEvent, render, screen } from "@testing-library/react"; +import { FormProvider, useForm } from "react-hook-form"; +import { describe, expect, test } from "vitest"; + +import { SimpleSearchBar } from "~/app/_components/search/simple-search-bar"; + +function Wrapper({ children }: { children: ReactNode }) { + const form = useForm({ defaultValues: { searchText: "" } }); + return {children}; +} + +describe("SimpleSearchBar", () => { + test("renders the search input with its placeholder", () => { + render( + + + , + ); + expect( + screen.getByPlaceholderText("Search for a job, company, industry..."), + ).toBeInTheDocument(); + }); + + test("is wired to the form field and reflects typed input", () => { + render( + + + , + ); + const input = screen.getByPlaceholderText( + "Search for a job, company, industry...", + ); + fireEvent.change(input, { target: { value: "google" } }); + expect(input).toHaveValue("google"); + }); +}); diff --git a/apps/web/test/components/star-graph.test.tsx b/apps/web/test/components/star-graph.test.tsx new file mode 100644 index 00000000..ef17ef20 --- /dev/null +++ b/apps/web/test/components/star-graph.test.tsx @@ -0,0 +1,76 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, test } from "vitest"; + +import StarGraph from "~/app/_components/shared/star-graph"; + +const ratings = [ + { stars: 5, percentage: 50 }, + { stars: 4, percentage: 30 }, + { stars: 3, percentage: 20 }, +]; + +describe("StarGraph", () => { + test("renders the average rating to one decimal place", () => { + render( + , + ); + expect(screen.getByText("4.3")).toBeInTheDocument(); + }); + + test("pluralizes the review count", () => { + render( + , + ); + expect(screen.getByText("Based on 10 reviews")).toBeInTheDocument(); + }); + + test("uses the singular form for a single review", () => { + render( + , + ); + expect(screen.getByText("Based on 1 review")).toBeInTheDocument(); + }); + + test("renders the Cooper average", () => { + render( + , + ); + expect(screen.getByText("Cooper average: 3.8")).toBeInTheDocument(); + }); + + test("renders a row per rating bucket with its computed count", () => { + render( + , + ); + // 50% of 10 reviews -> 5 + expect(screen.getByText("5")).toBeInTheDocument(); + // 30% of 10 -> 3 + expect(screen.getByText("3")).toBeInTheDocument(); + expect(screen.getByText("5 stars")).toBeInTheDocument(); + }); +}); diff --git a/apps/web/test/components/tab-toggle.test.tsx b/apps/web/test/components/tab-toggle.test.tsx new file mode 100644 index 00000000..3b4c77f1 --- /dev/null +++ b/apps/web/test/components/tab-toggle.test.tsx @@ -0,0 +1,38 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, test, vi } from "vitest"; + +import { TabToggle } from "~/app/_components/roles/modals/shared/tab-toggle"; + +describe("TabToggle", () => { + test("renders both tabs", () => { + render(); + expect(screen.getByRole("button", { name: "Total" })).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Industry" }), + ).toBeInTheDocument(); + }); + + test("highlights the active tab", () => { + render(); + expect( + screen.getByRole("button", { name: "Industry" }).className, + ).toContain("bg-cooper-gray-125"); + expect(screen.getByRole("button", { name: "Total" }).className).toContain( + "bg-white", + ); + }); + + test("calls onChange with 'total' when Total is clicked", () => { + const onChange = vi.fn(); + render(); + fireEvent.click(screen.getByRole("button", { name: "Total" })); + expect(onChange).toHaveBeenCalledWith("total"); + }); + + test("calls onChange with 'industry' when Industry is clicked", () => { + const onChange = vi.fn(); + render(); + fireEvent.click(screen.getByRole("button", { name: "Industry" })); + expect(onChange).toHaveBeenCalledWith("industry"); + }); +}); diff --git a/apps/web/test/components/themed-input.test.tsx b/apps/web/test/components/themed-input.test.tsx new file mode 100644 index 00000000..8efbec76 --- /dev/null +++ b/apps/web/test/components/themed-input.test.tsx @@ -0,0 +1,25 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, test, vi } from "vitest"; + +import { Input } from "~/app/_components/themed/onboarding/input"; + +describe("themed Input", () => { + test("renders the underlying input with passed props", () => { + render(); + expect(screen.getByPlaceholderText("Your name")).toBeInTheDocument(); + }); + + test("does not render a clear button without onClear", () => { + render(); + expect( + screen.queryByRole("button", { name: "Clear input" }), + ).not.toBeInTheDocument(); + }); + + test("renders and fires the clear button when onClear is provided", () => { + const onClear = vi.fn(); + render(); + fireEvent.click(screen.getByRole("button", { name: "Clear input" })); + expect(onClear).toHaveBeenCalledOnce(); + }); +}); diff --git a/apps/web/test/components/themed-select.test.tsx b/apps/web/test/components/themed-select.test.tsx new file mode 100644 index 00000000..a57a4ed9 --- /dev/null +++ b/apps/web/test/components/themed-select.test.tsx @@ -0,0 +1,49 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, test, vi } from "vitest"; + +import { Select } from "~/app/_components/themed/onboarding/select"; + +const options = [ + { value: "co-op", label: "Co-op" }, + { value: "intern", label: "Internship" }, +]; + +describe("themed Select", () => { + test("renders an option per item", () => { + render(); + const placeholder = screen.getByText("Select a type"); + expect(placeholder).toBeInTheDocument(); + expect(placeholder).toHaveValue(""); + }); + + test("fires onChange when a value is selected", () => { + const onChange = vi.fn(); + render(); + fireEvent.click(screen.getByRole("button", { name: "Clear selection" })); + expect(onClear).toHaveBeenCalledOnce(); + }); + + test("omits the clear button without onClear", () => { + render( onSearchChange(e.target.value)} + /> +
    + {options.map((opt) => ( +
  • {opt.label}
  • + ))} +
+ +
+ ), +})); + +vi.mock("~/trpc/react", () => ({ + api: { tool: { getCommon: { useQuery } } }, +})); + +import { ToolsAutocomplete } from "~/app/_components/form/sections/tools-autocomplete"; + +describe("ToolsAutocomplete", () => { + test("maps common tools from the query into options", () => { + useQuery.mockReturnValue({ + data: [{ name: "React" }, { name: "Figma" }], + }); + render(); + expect(screen.getByText("React")).toBeInTheDocument(); + expect(screen.getByText("Figma")).toBeInTheDocument(); + }); + + test("keeps selected custom values as options even when not common", () => { + useQuery.mockReturnValue({ data: [{ name: "React" }] }); + render(); + expect(screen.getByText("Custom Tool")).toBeInTheDocument(); + }); + + test("appends the trimmed search term as a new option", () => { + useQuery.mockReturnValue({ data: [{ name: "React" }] }); + render(); + fireEvent.change(screen.getByTestId("search"), { + target: { value: " Postman " }, + }); + expect(screen.getByText("Postman")).toBeInTheDocument(); + }); + + test("does not duplicate an option that already exists for the search", () => { + useQuery.mockReturnValue({ data: [{ name: "React" }] }); + render(); + fireEvent.change(screen.getByTestId("search"), { + target: { value: "react" }, + }); + expect(screen.getAllByText(/^React$/)).toHaveLength(1); + }); + + test("forwards selection changes through onChange", () => { + useQuery.mockReturnValue({ data: [{ name: "React" }] }); + const onChange = vi.fn(); + render(); + fireEvent.click(screen.getByText("select-react")); + expect(onChange).toHaveBeenCalledWith(["React"]); + }); + + test("renders without options when the query has no data", () => { + useQuery.mockReturnValue({ data: undefined }); + render(); + expect(screen.getByTestId("options").children).toHaveLength(0); + }); +}); diff --git a/apps/web/test/components/useFavoriteToggle.test.ts b/apps/web/test/components/useFavoriteToggle.test.ts new file mode 100644 index 00000000..92a9b103 --- /dev/null +++ b/apps/web/test/components/useFavoriteToggle.test.ts @@ -0,0 +1,364 @@ +import { renderHook } from "@testing-library/react"; +import { beforeEach, describe, expect, test, vi } from "vitest"; + +interface MutationOpts { + onMutate: () => Promise<{ prev: unknown }>; + onSuccess: () => void; + onError: (err: unknown, vars: unknown, ctx?: { prev: unknown }) => void; + onSettled: () => unknown; +} + +const h = vi.hoisted(() => { + // setData invokes its updater with a representative list so the updater + // bodies (spreads/filters) actually execute and count toward coverage. + const sampleList = [ + { roleId: "r1", companyId: "c1", profileId: "profile-1" }, + ]; + const makeUtils = () => ({ + invalidate: vi.fn().mockResolvedValue(undefined), + cancel: vi.fn().mockResolvedValue(undefined), + setData: vi.fn((_input: unknown, updater: unknown) => + typeof updater === "function" + ? (updater as (l: unknown) => unknown)(sampleList) + : updater, + ), + getData: vi.fn(() => []), + }); + return { + roleUtils: makeUtils(), + companyUtils: makeUtils(), + mutate: { + favoriteRole: vi.fn(), + unfavoriteRole: vi.fn(), + favoriteCompany: vi.fn(), + unfavoriteCompany: vi.fn(), + }, + opts: {} as { + favoriteRole: MutationOpts; + unfavoriteRole: MutationOpts; + favoriteCompany: MutationOpts; + unfavoriteCompany: MutationOpts; + [key: string]: MutationOpts; + }, + toast: { success: vi.fn(), error: vi.fn() }, + state: { + profileData: undefined as { id: string } | undefined, + roleList: [] as { roleId: string; profileId: string }[], + companyList: [] as { companyId: string; profileId: string }[], + }, + }; +}); + +vi.mock("@cooper/ui/hooks/use-custom-toast", () => ({ + useCustomToast: () => ({ toast: h.toast }), +})); + +vi.mock("~/trpc/react", () => { + const mutation = (key: string, mutate: () => void) => ({ + useMutation: (opts: MutationOpts) => { + h.opts[key] = opts; + return { mutate }; + }, + }); + return { + api: { + useUtils: () => ({ + profile: { + listFavoriteRoles: h.roleUtils, + listFavoriteCompanies: h.companyUtils, + }, + }), + profile: { + getCurrentUser: { useQuery: () => ({ data: h.state.profileData }) }, + listFavoriteRoles: { + useQuery: () => ({ data: h.state.roleList, isLoading: false }), + }, + listFavoriteCompanies: { + useQuery: () => ({ data: h.state.companyList, isLoading: false }), + }, + favoriteRole: mutation("favoriteRole", h.mutate.favoriteRole), + unfavoriteRole: mutation("unfavoriteRole", h.mutate.unfavoriteRole), + favoriteCompany: mutation("favoriteCompany", h.mutate.favoriteCompany), + unfavoriteCompany: mutation( + "unfavoriteCompany", + h.mutate.unfavoriteCompany, + ), + }, + }, + }; +}); + +import { useFavoriteToggle } from "~/app/_components/shared/useFavoriteToggle"; + +describe("useFavoriteToggle", () => { + beforeEach(() => { + vi.clearAllMocks(); + h.opts = {} as typeof h.opts; + h.state.profileData = { id: "profile-1" }; + h.state.roleList = []; + h.state.companyList = []; + }); + + describe("derived state", () => { + test("exposes the current profile id", () => { + const { result } = renderHook(() => useFavoriteToggle("r1", "role")); + expect(result.current.profileId).toBe("profile-1"); + }); + + test("profileId is empty when there is no profile", () => { + h.state.profileData = undefined; + const { result } = renderHook(() => useFavoriteToggle("r1", "role")); + expect(result.current.profileId).toBe(""); + }); + + test("exposes the list loading state", () => { + const { result } = renderHook(() => useFavoriteToggle("r1", "role")); + expect(result.current.isLoading).toBe(false); + }); + + test("reports a role as favorited when it is in the list", () => { + h.state.roleList = [{ roleId: "r1", profileId: "profile-1" }]; + const { result } = renderHook(() => useFavoriteToggle("r1", "role")); + expect(result.current.isFavorited).toBe(true); + }); + + test("reports a role as not favorited when absent", () => { + h.state.roleList = [{ roleId: "other", profileId: "profile-1" }]; + const { result } = renderHook(() => useFavoriteToggle("r1", "role")); + expect(result.current.isFavorited).toBe(false); + }); + + test("reports a company as favorited when it is in the list", () => { + h.state.companyList = [{ companyId: "c1", profileId: "profile-1" }]; + const { result } = renderHook(() => useFavoriteToggle("c1", "company")); + expect(result.current.isFavorited).toBe(true); + }); + + test("reports a company as not favorited when absent", () => { + h.state.companyList = [{ companyId: "other", profileId: "profile-1" }]; + const { result } = renderHook(() => useFavoriteToggle("c1", "company")); + expect(result.current.isFavorited).toBe(false); + }); + }); + + describe("toggle", () => { + test("favorites a role that is not yet favorited", () => { + const { result } = renderHook(() => useFavoriteToggle("r1", "role")); + result.current.toggle(); + expect(h.mutate.favoriteRole).toHaveBeenCalledWith({ + profileId: "profile-1", + roleId: "r1", + }); + expect(h.mutate.unfavoriteRole).not.toHaveBeenCalled(); + }); + + test("unfavorites a role that is already favorited", () => { + h.state.roleList = [{ roleId: "r1", profileId: "profile-1" }]; + const { result } = renderHook(() => useFavoriteToggle("r1", "role")); + result.current.toggle(); + expect(h.mutate.unfavoriteRole).toHaveBeenCalledWith({ + profileId: "profile-1", + roleId: "r1", + }); + }); + + test("favorites a company that is not yet favorited", () => { + const { result } = renderHook(() => useFavoriteToggle("c1", "company")); + result.current.toggle(); + expect(h.mutate.favoriteCompany).toHaveBeenCalledWith({ + profileId: "profile-1", + companyId: "c1", + }); + }); + + test("unfavorites a company that is already favorited", () => { + h.state.companyList = [{ companyId: "c1", profileId: "profile-1" }]; + const { result } = renderHook(() => useFavoriteToggle("c1", "company")); + result.current.toggle(); + expect(h.mutate.unfavoriteCompany).toHaveBeenCalledWith({ + profileId: "profile-1", + companyId: "c1", + }); + }); + + test("is a no-op without a profile", () => { + h.state.profileData = undefined; + const { result } = renderHook(() => useFavoriteToggle("r1", "role")); + result.current.toggle(); + expect(h.mutate.favoriteRole).not.toHaveBeenCalled(); + expect(h.mutate.unfavoriteRole).not.toHaveBeenCalled(); + }); + }); + + describe("favoriteRole mutation callbacks", () => { + beforeEach(() => { + renderHook(() => useFavoriteToggle("r1", "role")); + }); + + test("onMutate cancels the query, snapshots, and optimistically adds", async () => { + const ctx = await h.opts.favoriteRole.onMutate(); + expect(h.roleUtils.cancel).toHaveBeenCalledWith({ + profileId: "profile-1", + }); + expect(h.roleUtils.getData).toHaveBeenCalled(); + expect(h.roleUtils.setData).toHaveBeenCalled(); + expect(ctx).toHaveProperty("prev"); + }); + + test("onSuccess shows a success toast", () => { + h.opts.favoriteRole.onSuccess(); + expect(h.toast.success).toHaveBeenCalledWith("This role has been saved."); + }); + + test("onError rolls back from an array snapshot and toasts", () => { + h.opts.favoriteRole.onError( + {}, + {}, + { + prev: [{ roleId: "r1", profileId: "profile-1" }], + }, + ); + expect(h.roleUtils.setData).toHaveBeenCalled(); + expect(h.toast.error).toHaveBeenCalledWith("Oops. Please try again."); + }); + + test("onError handles a non-array snapshot", () => { + h.opts.favoriteRole.onError({}, {}, { prev: "not-an-array" }); + expect(h.toast.error).toHaveBeenCalled(); + }); + + test("onError without a prev context still toasts", () => { + h.opts.favoriteRole.onError({}, {}, undefined); + expect(h.toast.error).toHaveBeenCalled(); + }); + + test("onSettled invalidates the role list", async () => { + await h.opts.favoriteRole.onSettled(); + expect(h.roleUtils.invalidate).toHaveBeenCalledWith({ + profileId: "profile-1", + }); + }); + }); + + describe("unfavoriteRole mutation callbacks", () => { + beforeEach(() => { + renderHook(() => useFavoriteToggle("r1", "role")); + }); + + test("onMutate cancels, snapshots, and optimistically removes", async () => { + const ctx = await h.opts.unfavoriteRole.onMutate(); + expect(h.roleUtils.cancel).toHaveBeenCalled(); + expect(h.roleUtils.setData).toHaveBeenCalled(); + expect(ctx).toHaveProperty("prev"); + }); + + test("onSuccess shows an unsaved toast", () => { + h.opts.unfavoriteRole.onSuccess(); + expect(h.toast.success).toHaveBeenCalledWith( + "This role has been unsaved.", + ); + }); + + test("onError rolls back and toasts", () => { + h.opts.unfavoriteRole.onError( + {}, + {}, + { + prev: [{ roleId: "r1", profileId: "profile-1" }], + }, + ); + expect(h.toast.error).toHaveBeenCalled(); + }); + + test("onSettled invalidates the role list", async () => { + await h.opts.unfavoriteRole.onSettled(); + expect(h.roleUtils.invalidate).toHaveBeenCalled(); + }); + }); + + describe("favoriteCompany mutation callbacks", () => { + beforeEach(() => { + renderHook(() => useFavoriteToggle("c1", "company")); + }); + + test("onMutate cancels, snapshots, and optimistically adds", async () => { + const ctx = await h.opts.favoriteCompany.onMutate(); + expect(h.companyUtils.cancel).toHaveBeenCalled(); + expect(h.companyUtils.getData).toHaveBeenCalled(); + expect(h.companyUtils.setData).toHaveBeenCalled(); + expect(ctx).toHaveProperty("prev"); + }); + + test("onSuccess shows a success toast", () => { + h.opts.favoriteCompany.onSuccess(); + expect(h.toast.success).toHaveBeenCalledWith( + "This company has been saved.", + ); + }); + + test("onError rolls back from an array snapshot and toasts", () => { + h.opts.favoriteCompany.onError( + {}, + {}, + { + prev: [{ companyId: "c1", profileId: "profile-1" }], + }, + ); + expect(h.companyUtils.setData).toHaveBeenCalled(); + expect(h.toast.error).toHaveBeenCalled(); + }); + + test("onError handles a non-array snapshot", () => { + h.opts.favoriteCompany.onError({}, {}, { prev: 42 }); + expect(h.toast.error).toHaveBeenCalled(); + }); + + test("onSettled invalidates the company list", async () => { + await h.opts.favoriteCompany.onSettled(); + expect(h.companyUtils.invalidate).toHaveBeenCalledWith({ + profileId: "profile-1", + }); + }); + }); + + describe("unfavoriteCompany mutation callbacks", () => { + beforeEach(() => { + renderHook(() => useFavoriteToggle("c1", "company")); + }); + + test("onMutate cancels, snapshots, and optimistically removes", async () => { + const ctx = await h.opts.unfavoriteCompany.onMutate(); + expect(h.companyUtils.cancel).toHaveBeenCalled(); + expect(h.companyUtils.setData).toHaveBeenCalled(); + expect(ctx).toHaveProperty("prev"); + }); + + test("onSuccess shows an unsaved toast", () => { + h.opts.unfavoriteCompany.onSuccess(); + expect(h.toast.success).toHaveBeenCalledWith( + "This company has been unsaved.", + ); + }); + + test("onError rolls back and toasts", () => { + h.opts.unfavoriteCompany.onError( + {}, + {}, + { + prev: [{ companyId: "c1", profileId: "profile-1" }], + }, + ); + expect(h.toast.error).toHaveBeenCalled(); + }); + + test("onError without a prev context still toasts", () => { + h.opts.unfavoriteCompany.onError({}, {}, undefined); + expect(h.toast.error).toHaveBeenCalled(); + }); + + test("onSettled invalidates the company list", async () => { + await h.opts.unfavoriteCompany.onSettled(); + expect(h.companyUtils.invalidate).toHaveBeenCalled(); + }); + }); +}); diff --git a/apps/web/test/components/user-manager-table.test.tsx b/apps/web/test/components/user-manager-table.test.tsx new file mode 100644 index 00000000..1e488264 --- /dev/null +++ b/apps/web/test/components/user-manager-table.test.tsx @@ -0,0 +1,121 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { beforeEach, describe, expect, test, vi } from "vitest"; + +const updateRoleMutate = vi.fn(); +const updateDisabledMutate = vi.fn(); +const invalidate = vi.fn(); + +let queryResult: { + data?: { items: unknown[] }; + isLoading: boolean; +} = { data: { items: [] }, isLoading: false }; + +vi.mock("~/trpc/react", () => ({ + api: { + useUtils: () => ({ + admin: { userManagerItems: { invalidate } }, + }), + admin: { + userManagerItems: { + useQuery: () => queryResult, + }, + updateUserRole: { + useMutation: () => ({ mutate: updateRoleMutate, isPending: false }), + }, + updateUserDisabled: { + useMutation: () => ({ mutate: updateDisabledMutate, isPending: false }), + }, + }, + }, +})); + +vi.mock("@cooper/ui", () => ({ + cn: (...inputs: unknown[]) => inputs.flat().filter(Boolean).join(" "), + useCustomToast: () => ({ + toast: { custom: vi.fn().mockReturnValue({ dismiss: vi.fn() }) }, + }), +})); + +import { AdminUserManagerTable } from "~/app/_components/admin/user-manager-table"; + +const users = [ + { + id: "1", + name: "Alice Admin", + email: "alice@husky.neu.edu", + role: "ADMIN", + isDisabled: false, + createdAt: new Date("2024-01-01").toISOString(), + }, + { + id: "2", + name: "Bob Student", + email: "bob@husky.neu.edu", + role: "STUDENT", + isDisabled: false, + createdAt: new Date("2024-02-01").toISOString(), + }, + { + id: "3", + name: "Carol Coordinator", + email: "carol@husky.neu.edu", + role: "COORDINATOR", + isDisabled: true, + createdAt: new Date("2024-03-01").toISOString(), + }, +]; + +beforeEach(() => { + vi.clearAllMocks(); + queryResult = { data: { items: users }, isLoading: false }; +}); + +describe("AdminUserManagerTable", () => { + test("renders a loading row while loading", () => { + queryResult = { data: undefined, isLoading: true }; + render(); + expect(screen.getByText("Loading users...")).toBeInTheDocument(); + }); + + test("renders the users", () => { + render(); + expect(screen.getByText("Alice Admin")).toBeInTheDocument(); + expect(screen.getByText("Bob Student")).toBeInTheDocument(); + }); + + test("filters by search query", () => { + render(); + fireEvent.change(screen.getByPlaceholderText("Search for admins"), { + target: { value: "alice" }, + }); + expect(screen.getByText("Alice Admin")).toBeInTheDocument(); + expect(screen.queryByText("Bob Student")).toBeNull(); + }); + + test("filters by role", () => { + render(); + fireEvent.change(screen.getByLabelText("Filter role"), { + target: { value: "STUDENT" }, + }); + expect(screen.getByText("Bob Student")).toBeInTheDocument(); + expect(screen.queryByText("Alice Admin")).toBeNull(); + }); + + test("sorts alphabetically", () => { + render(); + fireEvent.change(screen.getByLabelText("Sort users"), { + target: { value: "name-asc" }, + }); + expect(screen.getByText("Alice Admin")).toBeInTheDocument(); + }); + + test("shows an empty state when no users match", () => { + render(); + fireEvent.change(screen.getByPlaceholderText("Search for admins"), { + target: { value: "zzzzz" }, + }); + expect( + screen.getByText("No users match your filters."), + ).toBeInTheDocument(); + }); +}); diff --git a/apps/web/test/dateHelpers.test.ts b/apps/web/test/dateHelpers.test.ts new file mode 100644 index 00000000..9dc03973 --- /dev/null +++ b/apps/web/test/dateHelpers.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, test } from "vitest"; + +import { formatDate, formatLastEditedDate } from "~/utils/dateHelpers"; + +describe("formatDate", () => { + test("returns empty string when no date provided", () => { + expect(formatDate()).toBe(""); + }); + + test("formats a date as 'Mth dd, yyyy'", () => { + // Jan 5, 2024 (month is 0-indexed) + expect(formatDate(new Date(2024, 0, 5))).toBe("Jan 05, 2024"); + }); + + test("pads single-digit days to two digits", () => { + expect(formatDate(new Date(2023, 11, 9))).toBe("Dec 09, 2023"); + }); + + test("handles double-digit days", () => { + expect(formatDate(new Date(2022, 6, 24))).toBe("Jul 24, 2022"); + }); +}); + +describe("formatLastEditedDate", () => { + const minutesAgo = (n: number) => new Date(Date.now() - n * 60 * 1000); + const hoursAgo = (n: number) => new Date(Date.now() - n * 60 * 60 * 1000); + const daysAgo = (n: number) => new Date(Date.now() - n * 24 * 60 * 60 * 1000); + + test("returns empty string when neither date provided", () => { + expect(formatLastEditedDate()).toBe(""); + }); + + test("prefers updatedAt over createdAt", () => { + expect(formatLastEditedDate(minutesAgo(5), daysAgo(10))).toBe( + "Last edited 5 minutes ago", + ); + }); + + test("falls back to createdAt when updatedAt is null", () => { + expect(formatLastEditedDate(null, minutesAgo(10))).toBe( + "Last edited 10 minutes ago", + ); + }); + + test("reports hours when under a day", () => { + expect(formatLastEditedDate(hoursAgo(3))).toBe("Last edited 3 hours ago"); + }); + + test("reports a single day", () => { + expect(formatLastEditedDate(daysAgo(1))).toBe("Last edited 1 day ago"); + }); + + test("reports multiple days when under a week", () => { + expect(formatLastEditedDate(daysAgo(4))).toBe("Last edited 4 days ago"); + }); + + test("falls back to a formatted date when older than a week", () => { + const old = new Date(2020, 2, 15); + expect(formatLastEditedDate(old)).toBe(`Last edited ${formatDate(old)}`); + }); +}); diff --git a/apps/web/test/locationHelpers.test.ts b/apps/web/test/locationHelpers.test.ts new file mode 100644 index 00000000..27af40cf --- /dev/null +++ b/apps/web/test/locationHelpers.test.ts @@ -0,0 +1,121 @@ +import { describe, expect, test, vi } from "vitest"; + +import type { LocationType } from "@cooper/db/schema"; + +// locationHelpers imports the tRPC react client at module load; stub it so the +// module can be imported in a test environment without a real tRPC provider. +vi.mock("~/trpc/react", () => ({ + api: { + location: { + getById: { + useQuery: () => ({ data: undefined }), + }, + }, + }, +})); + +const { abbreviatedStateName, prettyLocationName } = await import( + "~/utils/locationHelpers" +); + +const location = (overrides: Partial = {}): LocationType => + ({ + city: "Boston", + state: "Massachusetts", + country: "USA", + ...overrides, + }) as LocationType; + +describe("prettyLocationName", () => { + test("returns 'N/A' when no location is given", () => { + expect(prettyLocationName(undefined)).toBe("N/A"); + }); + + test("renders city and abbreviated state when a state exists", () => { + expect(prettyLocationName(location({ state: "Massachusetts" }))).toBe( + "Boston, MA", + ); + }); + + test("renders city and country when there is no state", () => { + expect( + prettyLocationName( + location({ city: "Toronto", state: "", country: "Canada" }), + ), + ).toBe("Toronto, Canada"); + }); +}); + +describe("abbreviatedStateName", () => { + test("upper-cases an already-abbreviated two-letter state", () => { + expect(abbreviatedStateName("ny")).toBe("NY"); + }); + + test("returns the input unchanged for unknown full names", () => { + expect(abbreviatedStateName("Atlantis")).toBe("Atlantis"); + }); + + const states: [string, string][] = [ + ["Alabama", "AL"], + ["Alaska", "AK"], + ["Arizona", "AZ"], + ["Arkansas", "AR"], + ["American Samoa", "AS"], + ["California", "CA"], + ["Colorado", "CO"], + ["Connecticut", "CT"], + ["Delaware", "DE"], + ["District of Columbia", "DC"], + ["Florida", "FL"], + ["Georgia", "GA"], + ["Guam", "GU"], + ["Hawaii", "HI"], + ["Idaho", "ID"], + ["Illinois", "IL"], + ["Indiana", "IN"], + ["Iowa", "IA"], + ["Kansas", "KS"], + ["Kentucky", "KY"], + ["Louisiana", "LA"], + ["Maine", "ME"], + ["Maryland", "MD"], + ["Massachusetts", "MA"], + ["Michigan", "MI"], + ["Minnesota", "MN"], + ["Mississippi", "MS"], + ["Missouri", "MO"], + ["Montana", "MT"], + ["Nebraska", "NE"], + ["Nevada", "NV"], + ["New Hampshire", "NH"], + ["New Jersey", "NJ"], + ["New Mexico", "NM"], + ["New York", "NY"], + ["North Carolina", "NC"], + ["North Dakota", "ND"], + ["North Mariana Islands", "MP"], + ["Ohio", "OH"], + ["Oklahoma", "OK"], + ["Oregon", "OR"], + ["Pennsylvania", "PA"], + ["Puerto Rico", "PR"], + ["Rhode Island", "RI"], + ["South Carolina", "SC"], + ["South Dakota", "SD"], + ["Tennessee", "TN"], + ["Texas", "TX"], + ["Trust Territories", "TT"], + ["Utah", "UT"], + ["Vermont", "VT"], + ["Virginia", "VA"], + ["Virgin Islands", "VI"], + ["Washington", "WA"], + ["West Virginia", "WV"], + ["Wisconsin", "WI"], + ["Wyoming", "WY"], + ]; + + test.each(states)("maps %s to %s", (full, abbr) => { + expect(abbreviatedStateName(full)).toBe(abbr); + }); +}); diff --git a/apps/web/test/reviewAggregation.test.ts b/apps/web/test/reviewAggregation.test.ts new file mode 100644 index 00000000..fd0bed02 --- /dev/null +++ b/apps/web/test/reviewAggregation.test.ts @@ -0,0 +1,134 @@ +import { describe, expect, test } from "vitest"; + +import type { ReviewType } from "@cooper/db/schema"; + +import { calculateRatings } from "~/utils/reviewCountByStars"; +import { + averageStarRating, + listBenefits, + mostCommonWorkEnviornment, +} from "~/utils/reviewsAggregationHelpers"; + +const review = (overrides: Partial = {}): ReviewType => + ({ + overallRating: 5, + workEnvironment: "REMOTE", + pto: false, + federalHolidays: false, + freeLunch: false, + travelBenefits: false, + freeMerch: false, + snackBar: false, + ...overrides, + }) as ReviewType; + +describe("calculateRatings", () => { + test("returns 0% for every star bucket when there are no reviews", () => { + expect(calculateRatings([])).toEqual([ + { stars: 5, percentage: 0 }, + { stars: 4, percentage: 0 }, + { stars: 3, percentage: 0 }, + { stars: 2, percentage: 0 }, + { stars: 1, percentage: 0 }, + ]); + }); + + test("defaults to empty array when called with no args", () => { + expect(calculateRatings()).toHaveLength(5); + }); + + test("computes the percentage distribution across stars", () => { + const reviews = [ + review({ overallRating: 5 }), + review({ overallRating: 5 }), + review({ overallRating: 4 }), + review({ overallRating: 1 }), + ]; + expect(calculateRatings(reviews)).toEqual([ + { stars: 5, percentage: 50 }, + { stars: 4, percentage: 25 }, + { stars: 3, percentage: 0 }, + { stars: 2, percentage: 0 }, + { stars: 1, percentage: 25 }, + ]); + }); +}); + +describe("mostCommonWorkEnviornment", () => { + test("returns In Person when in-person ties or leads", () => { + expect( + mostCommonWorkEnviornment([ + review({ workEnvironment: "INPERSON" }), + review({ workEnvironment: "REMOTE" }), + ]), + ).toBe("In Person"); + }); + + test("returns Hybrid when hybrid leads", () => { + expect( + mostCommonWorkEnviornment([ + review({ workEnvironment: "HYBRID" }), + review({ workEnvironment: "HYBRID" }), + review({ workEnvironment: "INPERSON" }), + ]), + ).toBe("Hybrid"); + }); + + test("returns Remote when remote leads", () => { + expect( + mostCommonWorkEnviornment([ + review({ workEnvironment: "REMOTE" }), + review({ workEnvironment: "REMOTE" }), + review({ workEnvironment: "HYBRID" }), + ]), + ).toBe("Remote"); + }); +}); + +describe("averageStarRating", () => { + test("averages the overall ratings", () => { + expect( + averageStarRating([ + review({ overallRating: 4 }), + review({ overallRating: 2 }), + ]), + ).toBe(3); + }); + + test("treats null ratings as 0", () => { + expect( + averageStarRating([ + review({ overallRating: null }), + review({ overallRating: 4 }), + ]), + ).toBe(2); + }); +}); + +describe("listBenefits", () => { + test("returns no benefits when none are set", () => { + expect(listBenefits(review())).toEqual([]); + }); + + test("lists every enabled benefit", () => { + expect( + listBenefits( + review({ + pto: true, + federalHolidays: true, + freeLunch: true, + travelBenefits: true, + freeMerch: true, + snackBar: true, + }), + ), + ).toEqual([ + "Paid Time Off", + "Federal Holidays Off", + "Free Lunch", + "Free Transporation", + "Free Merch", + "Snack Bar", + ]); + }); +}); diff --git a/apps/web/test/setup.ts b/apps/web/test/setup.ts new file mode 100644 index 00000000..186d00e6 --- /dev/null +++ b/apps/web/test/setup.ts @@ -0,0 +1,42 @@ +import "@testing-library/jest-dom/vitest"; + +import { cleanup } from "@testing-library/react"; +import { afterEach } from "vitest"; + +// jsdom has no PointerEvent, so Radix's `event.button === 0` open check never +// passes. Back it with MouseEvent (which carries `button`) so pointerdown opens. +if (typeof window.PointerEvent === "undefined") { + class PointerEvent extends MouseEvent { + public pointerId?: number; + public pointerType?: string; + constructor(type: string, params: PointerEventInit = {}) { + super(type, params); + this.pointerId = params.pointerId; + this.pointerType = params.pointerType; + } + } + window.PointerEvent = PointerEvent as typeof window.PointerEvent; +} + +// Radix UI relies on pointer-capture and scrollIntoView APIs that jsdom does +// not implement. Polyfill them so menus/popovers can open during tests. +if (!Element.prototype.hasPointerCapture) { + Element.prototype.hasPointerCapture = () => false; +} +if (!Element.prototype.setPointerCapture) { + Element.prototype.setPointerCapture = () => undefined; +} +if (!Element.prototype.releasePointerCapture) { + Element.prototype.releasePointerCapture = () => undefined; +} +if (!Element.prototype.scrollIntoView) { + Element.prototype.scrollIntoView = () => undefined; +} +if (!Element.prototype.scrollTo) { + Element.prototype.scrollTo = () => undefined; +} + +// Unmount React trees between tests so queries don't leak across cases. +afterEach(() => { + cleanup(); +}); diff --git a/apps/web/test/stringHelpers.test.ts b/apps/web/test/stringHelpers.test.ts new file mode 100644 index 00000000..13ff4502 --- /dev/null +++ b/apps/web/test/stringHelpers.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, test, vi } from "vitest"; + +import type { WorkEnvironmentType } from "@cooper/db/schema"; + +// Avoid loading the @cooper/ui barrel (pulls in .tsx components Vite can't +// transform here). stringHelpers only needs `cn` to join class strings. +vi.mock("@cooper/ui", () => ({ + cn: (...inputs: unknown[]) => inputs.flat().filter(Boolean).join(" "), +})); + +import { + abbreviatedWorkTerm, + prettyDescription, + prettyIndustry, + prettyWorkEnviornment, + truncateText, +} from "~/utils/stringHelpers"; + +describe("truncateText", () => { + test("truncates and appends ellipsis when text is at/over the length", () => { + expect(truncateText("hello world", 5)).toBe("hello ..."); + }); + + test("returns the text unchanged when shorter than the length", () => { + expect(truncateText("hi", 5)).toBe("hi"); + }); + + test("returns falsy text as-is", () => { + expect(truncateText("", 5)).toBe(""); + }); +}); + +describe("abbreviatedWorkTerm", () => { + test.each([ + ["SPRING", "Spr"], + ["SUMMER", "Sum"], + ["FALL", "Fall"], + ])("maps %s to %s", (input, expected) => { + expect(abbreviatedWorkTerm(input)).toBe(expected); + }); + + test("returns the input for unknown terms", () => { + expect(abbreviatedWorkTerm("WINTER")).toBe("WINTER"); + }); +}); + +describe("prettyWorkEnviornment", () => { + test.each([ + ["HYBRID", "Hybrid"], + ["INPERSON", "In-person"], + ["REMOTE", "Remote"], + ])("maps %s to %s", (input, expected) => { + expect(prettyWorkEnviornment(input as WorkEnvironmentType)).toBe(expected); + }); +}); + +describe("prettyDescription", () => { + test("returns empty string for nullish input", () => { + expect(prettyDescription()).toBe(""); + expect(prettyDescription(null)).toBe(""); + }); + + test("returns the description unchanged when under the limit", () => { + expect(prettyDescription("short", 200)).toBe("short"); + }); + + test("truncates and appends ellipsis when over the limit", () => { + const long = "a".repeat(250); + const result = prettyDescription(long); + expect(result).toBe("a".repeat(200) + "..."); + }); +}); + +describe("prettyIndustry", () => { + test("returns 'Unknown Industry' when undefined", () => { + expect(prettyIndustry()).toBe("Unknown Industry"); + }); + + test("returns 'Unknown Industry' for unrecognized values", () => { + expect(prettyIndustry("MADE_UP")).toBe("Unknown Industry"); + }); + + const cases: [string, string][] = [ + ["TECHNOLOGY", "Technology"], + ["HEALTHCARE", "Healthcare"], + ["FINANCE", "Finance"], + ["EDUCATION", "Education"], + ["MANUFACTURING", "Manufacturing"], + ["HOSPITALITY", "Hospitality"], + ["RETAIL", "Retail"], + ["TRANSPORTATION", "Transportation"], + ["ENERGY", "Energy"], + ["MEDIA", "Media"], + ["AEROSPACE", "Aerospace"], + ["TELECOMMUNICATIONS", "Telecommunications"], + ["BIOTECHNOLOGY", "Biotechnology"], + ["PHARMACEUTICAL", "Pharmaceutical"], + ["CONSTRUCTION", "Construction"], + ["REALESTATE", "Real Estate"], + ["FASHIONANDBEAUTY", "Fashion & Beauty"], + ["ENTERTAINMENT", "Entertainment"], + ["GOVERNMENT", "Government"], + ["NONPROFIT", "Nonprofit"], + ["FOODANDBEVERAGE", "Food & Beverage"], + ["GAMING", "Gaming"], + ["SPORTS", "Sports"], + ["MARKETING", "Marketing"], + ["CONSULTING", "Consulting"], + ["FITNESS", "Fitness"], + ["ECOMMERCE", "E-commerce"], + ["ENVIRONMENTAL", "Environmental"], + ["ROBOTICS", "Robotics"], + ["MUSIC", "Music"], + ["INSURANCE", "Insurance"], + ["DESIGN", "Design"], + ["PUBLISHING", "Publishing"], + ["ARCHITECTURE", "Architecture"], + ["VETERINARY", "Veterinary"], + ]; + + test.each(cases)("maps %s to %s", (input, expected) => { + expect(prettyIndustry(input)).toBe(expected); + }); +}); diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 15a3ca5b..db6b2902 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -12,5 +12,5 @@ "module": "esnext" }, "include": [".", ".next/types/**/*.ts"], - "exclude": ["node_modules"] + "exclude": ["node_modules", "coverage"] } diff --git a/apps/web/vitest.config.ts b/apps/web/vitest.config.ts new file mode 100644 index 00000000..faa4ea7c --- /dev/null +++ b/apps/web/vitest.config.ts @@ -0,0 +1,19 @@ +import { fileURLToPath } from "node:url"; +import react from "@vitejs/plugin-react"; +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + // plugin-react transforms JSX/TSX with the automatic runtime regardless of + // the app's `jsx: preserve` tsconfig, which oxc would otherwise leave as-is. + plugins: [react()], + resolve: { + alias: { + "~": fileURLToPath(new URL("./src", import.meta.url)), + }, + }, + test: { + environment: "jsdom", + setupFiles: ["./test/setup.ts"], + include: ["test/**/*.{test,spec}.{ts,tsx}"], + }, +}); diff --git a/package.json b/package.json index f5b5d3a7..6a716860 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "test": "vitest", "test:ui": "vitest --ui", "test:run": "vitest run", + "test:coverage": "vitest run --coverage", "__DATABASE__________": "", "db:push": "turbo run -F @cooper/db push", "db:generate": "turbo run -F @cooper/db generate", @@ -40,6 +41,8 @@ "@cooper/prettier-config": "workspace:*", "@turbo/gen": "^2.9.12", "@types/estree": "^1.0.9", + "@vitejs/plugin-react": "^6.0.2", + "@vitest/coverage-v8": "4.1.5", "@vitest/ui": "^4.1.5", "prettier": "catalog:", "turbo": "^2.9.12", diff --git a/packages/api/tests/admin.test.ts b/packages/api/tests/admin.test.ts new file mode 100644 index 00000000..d30e5d16 --- /dev/null +++ b/packages/api/tests/admin.test.ts @@ -0,0 +1,533 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; + +import { appRouter } from "../src/root"; +import { createCallerFactory, createTRPCContext } from "../src/trpc"; +import { adminSession, chain } from "./helpers"; + +const tx = vi.hoisted(() => ({ + execute: vi.fn(), + insert: vi.fn(), + update: vi.fn(), +})); + +const db = vi.hoisted(() => ({ + query: { + Review: { findMany: vi.fn() }, + Role: { findMany: vi.fn() }, + Company: { findMany: vi.fn() }, + Report: { findMany: vi.fn() }, + Flagged: { findFirst: vi.fn(), findMany: vi.fn() }, + Hidden: { findFirst: vi.fn(), findMany: vi.fn() }, + User: { findMany: vi.fn() }, + }, + insert: vi.fn(), + update: vi.fn(), + transaction: vi.fn(), +})); + +vi.mock("@cooper/db/client", () => ({ db })); +vi.mock("@cooper/auth", () => ({ + auth: { api: { getSession: vi.fn() } }, +})); + +async function caller() { + const ctx = await createTRPCContext({ + session: adminSession, + headers: new Headers(), + }); + return createCallerFactory(appRouter)(ctx); +} + +const USER_ID = "11111111-1111-1111-1111-111111111111"; +const ENTITY_ID = "22222222-2222-2222-2222-222222222222"; + +describe("admin router", () => { + beforeEach(() => { + vi.clearAllMocks(); + tx.execute.mockResolvedValue(undefined); + tx.insert.mockReturnValue(chain([{}])); + tx.update.mockReturnValue(chain([{}])); + db.transaction.mockImplementation((cb: (t: typeof tx) => unknown) => + cb(tx), + ); + // Default every relational read to empty so procedures that fetch a type + // they're not focused on don't blow up on `undefined.map(...)`. + db.query.Review.findMany.mockResolvedValue([]); + db.query.Role.findMany.mockResolvedValue([]); + db.query.Company.findMany.mockResolvedValue([]); + db.query.Report.findMany.mockResolvedValue([]); + db.query.Flagged.findMany.mockResolvedValue([]); + db.query.Hidden.findMany.mockResolvedValue([]); + }); + + test("userManagerItems maps users to a trimmed shape", async () => { + db.query.User.findMany.mockResolvedValue([ + { + id: "u1", + name: "Jane", + email: "jane@x.com", + role: "STUDENT", + isDisabled: false, + createdAt: new Date(), + extra: "dropped", + }, + ]); + + const result = await (await caller()).admin.userManagerItems({}); + + expect(result.items).toHaveLength(1); + expect(result.items[0]).toEqual( + expect.objectContaining({ + id: "u1", + name: "Jane", + email: "jane@x.com", + role: "STUDENT", + isDisabled: false, + }), + ); + expect(result.items[0]).not.toHaveProperty("extra"); + }); + + test("updateUserRole updates and returns success", async () => { + db.update.mockReturnValue(chain([{}])); + const result = await ( + await caller() + ).admin.updateUserRole({ + userId: USER_ID, + role: "ADMIN", + }); + expect(result).toEqual({ success: true }); + expect(db.update).toHaveBeenCalledOnce(); + }); + + test("updateUserDisabled updates and returns success", async () => { + db.update.mockReturnValue(chain([{}])); + const result = await ( + await caller() + ).admin.updateUserDisabled({ + userId: USER_ID, + isDisabled: true, + }); + expect(result).toEqual({ success: true }); + expect(db.update).toHaveBeenCalledOnce(); + }); + + test("setFlaggedStatus inserts a new flag when none is active", async () => { + db.query.Flagged.findFirst.mockResolvedValue(undefined); + db.insert.mockReturnValue(chain([{}])); + + const result = await ( + await caller() + ).admin.setFlaggedStatus({ + entityType: "review", + entityId: ENTITY_ID, + flagged: true, + }); + + expect(result).toEqual({ success: true }); + expect(db.insert).toHaveBeenCalledOnce(); + }); + + test("setFlaggedStatus deactivates the flag when unflagging", async () => { + db.update.mockReturnValue(chain([{}])); + + const result = await ( + await caller() + ).admin.setFlaggedStatus({ + entityType: "review", + entityId: ENTITY_ID, + flagged: false, + }); + + expect(result).toEqual({ success: true }); + expect(db.update).toHaveBeenCalledOnce(); + expect(db.insert).not.toHaveBeenCalled(); + }); + + test("setHiddenStatus hides a review within a transaction", async () => { + db.query.Hidden.findFirst.mockResolvedValue(undefined); + + const result = await ( + await caller() + ).admin.setHiddenStatus({ + entityType: "review", + entityId: ENTITY_ID, + hidden: true, + }); + + expect(result).toEqual({ success: true }); + expect(db.transaction).toHaveBeenCalledOnce(); + expect(tx.execute).toHaveBeenCalled(); + expect(tx.insert).toHaveBeenCalledOnce(); + }); + + test("setHiddenStatus unhides a review within a transaction", async () => { + db.query.Hidden.findFirst.mockResolvedValue({ id: "h1" }); + + const result = await ( + await caller() + ).admin.setHiddenStatus({ + entityType: "review", + entityId: ENTITY_ID, + hidden: false, + }); + + expect(result).toEqual({ success: true }); + expect(tx.update).toHaveBeenCalledOnce(); + expect(tx.execute).toHaveBeenCalled(); + }); + + test("setHiddenStatus hides a role within a transaction", async () => { + db.query.Hidden.findFirst.mockResolvedValue(undefined); + + const result = await ( + await caller() + ).admin.setHiddenStatus({ + entityType: "role", + entityId: ENTITY_ID, + hidden: true, + }); + + expect(result).toEqual({ success: true }); + expect(tx.execute).toHaveBeenCalled(); + expect(tx.insert).toHaveBeenCalledOnce(); + }); + + test("setHiddenStatus unhides a role within a transaction", async () => { + db.query.Hidden.findFirst.mockResolvedValue({ id: "h1" }); + + const result = await ( + await caller() + ).admin.setHiddenStatus({ + entityType: "role", + entityId: ENTITY_ID, + hidden: false, + }); + + expect(result).toEqual({ success: true }); + expect(tx.update).toHaveBeenCalledOnce(); + expect(tx.execute).toHaveBeenCalled(); + }); + + test("setHiddenStatus hides a company within a transaction", async () => { + db.query.Hidden.findFirst.mockResolvedValue(undefined); + + const result = await ( + await caller() + ).admin.setHiddenStatus({ + entityType: "company", + entityId: ENTITY_ID, + hidden: true, + }); + + expect(result).toEqual({ success: true }); + expect(tx.execute).toHaveBeenCalled(); + expect(tx.insert).toHaveBeenCalledOnce(); + }); + + test("setHiddenStatus unhides a company within a transaction", async () => { + db.query.Hidden.findFirst.mockResolvedValue({ id: "h1" }); + + const result = await ( + await caller() + ).admin.setHiddenStatus({ + entityType: "company", + entityId: ENTITY_ID, + hidden: false, + }); + + expect(result).toEqual({ success: true }); + expect(tx.update).toHaveBeenCalledOnce(); + expect(tx.execute).toHaveBeenCalled(); + }); + + describe("dashboard queries", () => { + const now = new Date("2026-01-03T00:00:00Z"); + const earlier = new Date("2026-01-01T00:00:00Z"); + + const review = (over: Record = {}) => ({ + id: "rev1", + roleId: "role1", + companyId: "comp1", + createdAt: now, + reviewHeadline: "Headline", + textReview: "Body text", + ...over, + }); + const role = (over: Record = {}) => ({ + id: "role1", + companyId: "comp1", + createdAt: earlier, + title: "Engineer", + ...over, + }); + const company = (over: Record = {}) => ({ + id: "comp1", + createdAt: earlier, + name: "Acme", + ...over, + }); + + test("dashboardItems returns sorted items and counts without a search", async () => { + db.query.Review.findMany.mockResolvedValue([review()]); + db.query.Role.findMany.mockResolvedValue([role()]); + db.query.Company.findMany.mockResolvedValue([]); + db.query.Flagged.findMany.mockResolvedValue([ + { entityType: "review", entityId: "rev1" }, + ]); + db.query.Hidden.findMany.mockResolvedValue([]); + + const result = await (await caller()).admin.dashboardItems({}); + + expect(result.counts).toEqual({ reviews: 1, roles: 1, companies: 0 }); + // Newest (review at `now`) is sorted ahead of the role at `earlier`. + expect(result.items[0]).toEqual( + expect.objectContaining({ + type: "review", + id: "rev1", + flagged: true, + hidden: false, + }), + ); + expect(result.items[1]).toEqual( + expect.objectContaining({ type: "role", id: "role1" }), + ); + }); + + test("dashboardItems expands a search across linked entities", async () => { + db.query.Review.findMany.mockResolvedValue([review()]); + db.query.Role.findMany.mockResolvedValue([role()]); + db.query.Company.findMany.mockResolvedValue([company()]); + + const result = await ( + await caller() + ).admin.dashboardItems({ + search: "ac", + }); + + expect(result.counts).toEqual({ reviews: 1, roles: 1, companies: 1 }); + }); + + test("dashboardItems returns nothing when the search matches no entities", async () => { + const result = await ( + await caller() + ).admin.dashboardItems({ + search: "no-such-thing", + }); + + expect(result.items).toEqual([]); + expect(result.counts).toEqual({ reviews: 0, roles: 0, companies: 0 }); + }); + + test("flaggedDashboardItems returns flagged items grouped by type", async () => { + db.query.Flagged.findMany.mockResolvedValue([ + { entityType: "review", entityId: "rev1", createdAt: now }, + { entityType: "role", entityId: "role1", createdAt: earlier }, + ]); + db.query.Review.findMany.mockResolvedValue([review()]); + db.query.Role.findMany.mockResolvedValue([role()]); + + const result = await (await caller()).admin.flaggedDashboardItems({}); + + expect(result.counts).toEqual({ reviews: 1, roles: 1, companies: 0 }); + expect(result.items.every((item) => item.flagged && !item.hidden)).toBe( + true, + ); + }); + + test("flaggedDashboardItems short-circuits when nothing is flagged", async () => { + db.query.Flagged.findMany.mockResolvedValue([]); + + const result = await (await caller()).admin.flaggedDashboardItems({}); + + expect(result).toEqual({ + items: [], + counts: { reviews: 0, roles: 0, companies: 0 }, + }); + }); + + test("flaggedDashboardItems intersects flags with a search expansion", async () => { + db.query.Flagged.findMany.mockResolvedValue([ + { entityType: "review", entityId: "rev1", createdAt: now }, + { entityType: "role", entityId: "role1", createdAt: now }, + { entityType: "company", entityId: "comp1", createdAt: now }, + { entityType: "review", entityId: "unmatched", createdAt: now }, + ]); + db.query.Review.findMany.mockResolvedValue([review()]); + db.query.Role.findMany.mockResolvedValue([role()]); + db.query.Company.findMany.mockResolvedValue([company()]); + + const result = await ( + await caller() + ).admin.flaggedDashboardItems({ + search: "ac", + }); + + expect(result.counts).toEqual({ reviews: 1, roles: 1, companies: 1 }); + }); + + test("flaggedDashboardItems returns empty when the search expansion is empty", async () => { + db.query.Flagged.findMany.mockResolvedValue([ + { entityType: "review", entityId: "rev1", createdAt: now }, + ]); + + const result = await ( + await caller() + ).admin.flaggedDashboardItems({ + search: "no-match", + }); + + expect(result.items).toEqual([]); + expect(result.counts).toEqual({ reviews: 0, roles: 0, companies: 0 }); + }); + + test("hiddenDashboardItems returns hidden items marked hidden", async () => { + db.query.Hidden.findMany.mockResolvedValue([ + { entityType: "role", entityId: "role1", createdAt: now }, + ]); + db.query.Role.findMany.mockResolvedValue([role()]); + + const result = await (await caller()).admin.hiddenDashboardItems({}); + + expect(result.counts).toEqual({ reviews: 0, roles: 1, companies: 0 }); + expect(result.items[0]).toEqual( + expect.objectContaining({ type: "role", hidden: true, flagged: false }), + ); + }); + + test("dashboardItems marks hidden entities", async () => { + db.query.Review.findMany.mockResolvedValue([review()]); + db.query.Role.findMany.mockResolvedValue([]); + db.query.Company.findMany.mockResolvedValue([]); + db.query.Flagged.findMany.mockResolvedValue([]); + db.query.Hidden.findMany.mockResolvedValue([ + { entityType: "review", entityId: "rev1" }, + ]); + + const result = await (await caller()).admin.dashboardItems({}); + + expect(result.items[0]).toEqual( + expect.objectContaining({ id: "rev1", hidden: true, flagged: false }), + ); + }); + + test("hiddenDashboardItems intersects hidden entities with a search expansion", async () => { + db.query.Hidden.findMany.mockResolvedValue([ + { entityType: "review", entityId: "rev1", createdAt: now }, + { entityType: "role", entityId: "role1", createdAt: now }, + { entityType: "company", entityId: "comp1", createdAt: now }, + ]); + db.query.Review.findMany.mockResolvedValue([review()]); + db.query.Role.findMany.mockResolvedValue([role()]); + db.query.Company.findMany.mockResolvedValue([company()]); + + const result = await ( + await caller() + ).admin.hiddenDashboardItems({ + search: "ac", + }); + + expect(result.counts).toEqual({ reviews: 1, roles: 1, companies: 1 }); + expect(result.items.every((item) => item.hidden && !item.flagged)).toBe( + true, + ); + }); + + test("hiddenDashboardItems returns empty when the search expansion is empty", async () => { + db.query.Hidden.findMany.mockResolvedValue([ + { entityType: "role", entityId: "role1", createdAt: now }, + ]); + + const result = await ( + await caller() + ).admin.hiddenDashboardItems({ + search: "no-match", + }); + + expect(result.items).toEqual([]); + expect(result.counts).toEqual({ reviews: 0, roles: 0, companies: 0 }); + }); + + test("reportedDashboardItems intersects reports with a search expansion", async () => { + db.query.Report.findMany.mockResolvedValue([ + { reviewId: "rev1", roleId: null, companyId: null, createdAt: now }, + { reviewId: null, roleId: "role1", companyId: null, createdAt: now }, + { reviewId: null, roleId: null, companyId: "comp1", createdAt: now }, + ]); + db.query.Review.findMany.mockResolvedValue([review()]); + db.query.Role.findMany.mockResolvedValue([role()]); + db.query.Company.findMany.mockResolvedValue([company()]); + db.query.Flagged.findMany.mockResolvedValue([]); + db.query.Hidden.findMany.mockResolvedValue([ + { entityType: "company", entityId: "comp1" }, + ]); + + const result = await ( + await caller() + ).admin.reportedDashboardItems({ + search: "ac", + }); + + expect(result.counts).toEqual({ reviews: 1, roles: 1, companies: 1 }); + const companyItem = result.items.find((item) => item.id === "comp1"); + expect(companyItem?.hidden).toBe(true); + }); + + test("hiddenDashboardItems short-circuits when nothing is hidden", async () => { + db.query.Hidden.findMany.mockResolvedValue([]); + + const result = await (await caller()).admin.hiddenDashboardItems({}); + + expect(result).toEqual({ + items: [], + counts: { reviews: 0, roles: 0, companies: 0 }, + }); + }); + + test("reportedDashboardItems gathers unique reported entities", async () => { + db.query.Report.findMany.mockResolvedValue([ + { reviewId: "rev1", roleId: null, companyId: null, createdAt: now }, + { reviewId: "rev1", roleId: null, companyId: null, createdAt: now }, + { reviewId: null, roleId: "role1", companyId: null, createdAt: now }, + ]); + db.query.Review.findMany.mockResolvedValue([review()]); + db.query.Role.findMany.mockResolvedValue([role()]); + db.query.Flagged.findMany.mockResolvedValue([ + { entityType: "review", entityId: "rev1" }, + ]); + db.query.Hidden.findMany.mockResolvedValue([]); + + const result = await (await caller()).admin.reportedDashboardItems({}); + + expect(result.counts).toEqual({ reviews: 1, roles: 1, companies: 0 }); + const reviewItem = result.items.find((item) => item.id === "rev1"); + expect(reviewItem?.flagged).toBe(true); + }); + + test("reportedDashboardItems short-circuits when there are no reports", async () => { + db.query.Report.findMany.mockResolvedValue([]); + + const result = await (await caller()).admin.reportedDashboardItems({}); + + expect(result).toEqual({ + items: [], + counts: { reviews: 0, roles: 0, companies: 0 }, + }); + }); + + test("reportedDashboardItems returns empty when the search expansion is empty", async () => { + db.query.Report.findMany.mockResolvedValue([ + { reviewId: "rev1", roleId: null, companyId: null, createdAt: now }, + ]); + + const result = await ( + await caller() + ).admin.reportedDashboardItems({ + search: "no-match", + }); + + expect(result.items).toEqual([]); + expect(result.counts).toEqual({ reviews: 0, roles: 0, companies: 0 }); + }); + }); +}); diff --git a/packages/api/tests/auth.test.ts b/packages/api/tests/auth.test.ts new file mode 100644 index 00000000..ff3a7365 --- /dev/null +++ b/packages/api/tests/auth.test.ts @@ -0,0 +1,79 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; + +import { appRouter } from "../src/root"; +import { createCallerFactory, createTRPCContext } from "../src/trpc"; +import { studentSession } from "./helpers"; + +const { invalidateSessionToken, getSession } = vi.hoisted(() => ({ + invalidateSessionToken: vi.fn(), + getSession: vi.fn(), +})); + +vi.mock("@cooper/db/client", () => ({ db: {} })); +vi.mock("@cooper/auth", () => ({ + auth: { api: { getSession } }, + invalidateSessionToken, +})); + +async function makeCaller(headers = new Headers()) { + const ctx = await createTRPCContext({ session: studentSession, headers }); + return createCallerFactory(appRouter)(ctx); +} + +describe("auth router", () => { + beforeEach(() => { + vi.clearAllMocks(); + getSession.mockResolvedValue(null); + }); + + test("getSession returns the current session", async () => { + const caller = await makeCaller(); + expect(await caller.auth.getSession()).toEqual(studentSession); + }); + + test("getSession returns null when unauthenticated", async () => { + const ctx = await createTRPCContext({ + session: null, + headers: new Headers(), + }); + const caller = createCallerFactory(appRouter)(ctx); + expect(await caller.auth.getSession()).toBeNull(); + }); + + test("getSecretMessage requires a session", async () => { + const ctx = await createTRPCContext({ + session: null, + headers: new Headers(), + }); + const caller = createCallerFactory(appRouter)(ctx); + await expect(caller.auth.getSecretMessage()).rejects.toMatchObject({ + code: "UNAUTHORIZED", + }); + }); + + test("getSecretMessage returns the message when authenticated", async () => { + const caller = await makeCaller(); + expect(await caller.auth.getSecretMessage()).toBe( + "you can see this secret message!", + ); + }); + + test("signOut invalidates the token when present", async () => { + const headers = new Headers({ Authorization: "Bearer abc123" }); + const caller = await makeCaller(headers); + + const result = await caller.auth.signOut(); + + expect(result).toEqual({ success: true }); + expect(invalidateSessionToken).toHaveBeenCalledWith("Bearer abc123"); + }); + + test("signOut is a no-op when no token is present", async () => { + const caller = await makeCaller(); + + const result = await caller.auth.signOut(); + + expect(result).toEqual({ success: false }); + expect(invalidateSessionToken).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/api/tests/company.test.ts b/packages/api/tests/company.test.ts new file mode 100644 index 00000000..f3c6626f --- /dev/null +++ b/packages/api/tests/company.test.ts @@ -0,0 +1,318 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; + +import { Status } from "@cooper/db/schema"; + +import { appRouter } from "../src/root"; +import { createCallerFactory, createTRPCContext } from "../src/trpc"; +import { chain, studentSession } from "./helpers"; + +const db = vi.hoisted(() => ({ + query: { + Company: { findFirst: vi.fn(), findMany: vi.fn() }, + Review: { findMany: vi.fn() }, + Role: { findMany: vi.fn() }, + }, + insert: vi.fn(), + execute: vi.fn(), +})); + +vi.mock("@cooper/db/client", () => ({ db })); +vi.mock("@cooper/auth", () => ({ + auth: { api: { getSession: vi.fn() } }, +})); + +async function caller() { + const ctx = await createTRPCContext({ + session: studentSession, + headers: new Headers(), + }); + return createCallerFactory(appRouter)(ctx); +} + +describe("company router", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + test("getBySlug returns a non-hidden company by slug", async () => { + const company = { id: "c1", slug: "acme", name: "Acme" }; + db.query.Company.findFirst.mockResolvedValue(company); + + const result = await (await caller()).company.getBySlug({ slug: "acme" }); + + expect(result).toEqual(company); + expect(db.query.Company.findFirst).toHaveBeenCalledOnce(); + }); + + test("getById returns a non-hidden company by id", async () => { + const company = { id: "c1", name: "Acme" }; + db.query.Company.findFirst.mockResolvedValue(company); + + const result = await (await caller()).company.getById({ id: "c1" }); + + expect(result).toEqual(company); + }); + + test("getAverageById averages the published reviews", async () => { + db.query.Review.findMany.mockResolvedValue([ + { + status: Status.PUBLISHED, + overallRating: 4, + hourlyPay: "20", + cultureRating: 5, + supervisorRating: 3, + }, + { + status: Status.PUBLISHED, + overallRating: 2, + hourlyPay: "30", + cultureRating: 3, + supervisorRating: 5, + }, + ]); + + const result = await ( + await caller() + ).company.getAverageById({ + companyId: "c1", + }); + + expect(result).toEqual({ + averageOverallRating: 3, + averageHourlyPay: 25, + averageCultureRating: 4, + averageSupervisorRating: 4, + }); + }); + + test("getAverageById returns zeros when there are no reviews", async () => { + db.query.Review.findMany.mockResolvedValue([]); + + const result = await ( + await caller() + ).company.getAverageById({ + companyId: "c1", + }); + + expect(result).toEqual({ + averageOverallRating: 0, + averageHourlyPay: 0, + averageCultureRating: 0, + averageSupervisorRating: 0, + }); + }); + + test("createWithRole rejects profane company names", async () => { + await expect( + (await caller()).company.createWithRole({ + companyName: "Shit Company", + description: "A perfectly fine description here", + industry: "TECHNOLOGY", + roleTitle: "Engineer", + roleDescription: "A perfectly fine role description", + createdBy: "profile-1", + }), + ).rejects.toMatchObject({ code: "PRECONDITION_FAILED" }); + }); + + test("createWithRole inserts a company and role and returns their ids", async () => { + db.query.Company.findMany.mockResolvedValue([]); + // Invoke the `where` callback so the role-slug scoping is exercised. + db.query.Role.findMany.mockImplementation((args) => { + args?.where?.({ companyId: "companyId" }, { eq: (a: unknown) => a }); + return Promise.resolve([]); + }); + db.insert + .mockReturnValueOnce(chain([{ id: "company-1" }])) + .mockReturnValueOnce(chain([{ id: "role-1" }])); + + const result = await ( + await caller() + ).company.createWithRole({ + companyName: "Acme Corp", + description: "A perfectly fine description here", + industry: "TECHNOLOGY", + roleTitle: "Engineer", + roleDescription: "A perfectly fine role description", + createdBy: "profile-1", + }); + + expect(result).toEqual({ roleId: "role-1", companyId: "company-1" }); + expect(db.insert).toHaveBeenCalledTimes(2); + }); + + describe("list (rating/default sort)", () => { + test("returns companies ordered by rating from the raw query", async () => { + const rows = [ + { id: "c2", name: "Beta", description: "b", avg_rating: 5 }, + { id: "c1", name: "Acme", description: "a", avg_rating: 3 }, + ]; + db.execute.mockResolvedValue({ rows }); + + const result = await (await caller()).company.list({}); + + expect(db.execute).toHaveBeenCalledOnce(); + expect(result.map((c) => c.id)).toEqual(["c2", "c1"]); + }); + + test("applies industry and location filters", async () => { + db.execute.mockResolvedValue({ rows: [{ id: "c1", name: "Acme" }] }); + + const result = await ( + await caller() + ).company.list({ + prefix: "Ac", + options: { industry: "TECHNOLOGY", location: "loc-1" }, + }); + + expect(db.execute).toHaveBeenCalledOnce(); + expect(result).toHaveLength(1); + }); + + test("adds a HAVING clause when showAll is false", async () => { + db.execute.mockResolvedValue({ rows: [{ id: "c1", name: "Acme" }] }); + + await (await caller()).company.list({ showAll: false }); + + expect(db.execute).toHaveBeenCalledOnce(); + }); + + test("respects the limit", async () => { + db.execute.mockResolvedValue({ + rows: [ + { id: "c1", name: "A" }, + { id: "c2", name: "B" }, + { id: "c3", name: "C" }, + ], + }); + + const result = await (await caller()).company.list({ limit: 2 }); + + expect(result).toHaveLength(2); + }); + }); + + describe("list (newest/oldest sort)", () => { + test("queries companies via the ORM ordering branch", async () => { + const companies = [ + { id: "c1", name: "Acme", description: "a" }, + { id: "c2", name: "Beta", description: "b" }, + ]; + db.query.Company.findMany.mockResolvedValue(companies); + + const result = await (await caller()).company.list({ sortBy: "newest" }); + + expect(db.query.Company.findMany).toHaveBeenCalledOnce(); + expect(result).toEqual(companies); + }); + + test("applies industry and location conditions", async () => { + db.query.Company.findMany.mockResolvedValue([{ id: "c1", name: "Acme" }]); + + await ( + await caller() + ).company.list({ + sortBy: "oldest", + prefix: "Ac", + options: { industry: "TECHNOLOGY", location: "loc-1" }, + }); + + expect(db.query.Company.findMany).toHaveBeenCalledOnce(); + }); + + test("keeps only companies with reviews when showAll is false", async () => { + db.query.Company.findMany.mockResolvedValue([ + { id: "c1", name: "Acme" }, + { id: "c2", name: "Beta" }, + ]); + db.execute.mockResolvedValue({ rows: [{ company_id: "c1" }] }); + + const result = await ( + await caller() + ).company.list({ + sortBy: "newest", + showAll: false, + }); + + expect(db.execute).toHaveBeenCalledOnce(); + expect(result.map((c) => c.id)).toEqual(["c1"]); + }); + }); + + describe("create", () => { + test("generates a unique slug and defaults the website", async () => { + db.query.Company.findMany.mockResolvedValue([{ slug: "acme" }]); + db.insert.mockReturnValue(chain([{ id: "c1", slug: "acme-1" }])); + + const result = await ( + await caller() + ).company.create({ + name: "Acme", + description: "A description", + industry: "TECHNOLOGY", + }); + + expect(db.insert).toHaveBeenCalledOnce(); + expect(result).toEqual([{ id: "c1", slug: "acme-1" }]); + }); + }); + + describe("createWithRole validation", () => { + const validInput = { + companyName: "Acme Corp", + description: "A perfectly fine description here", + industry: "TECHNOLOGY", + roleTitle: "Engineer", + roleDescription: "A perfectly fine role description", + createdBy: "profile-1", + }; + + test("rejects a profane description", async () => { + await expect( + (await caller()).company.createWithRole({ + ...validInput, + description: "This shit is broken and unusable", + }), + ).rejects.toMatchObject({ code: "PRECONDITION_FAILED" }); + }); + + test("rejects a profane role title", async () => { + await expect( + (await caller()).company.createWithRole({ + ...validInput, + roleTitle: "Shit Engineer", + }), + ).rejects.toMatchObject({ code: "PRECONDITION_FAILED" }); + }); + + test("rejects a profane role description", async () => { + await expect( + (await caller()).company.createWithRole({ + ...validInput, + roleDescription: "You will shovel shit all day every day", + }), + ).rejects.toMatchObject({ code: "PRECONDITION_FAILED" }); + }); + + test("throws when the company insert returns no id", async () => { + db.query.Company.findMany.mockResolvedValue([]); + db.insert.mockReturnValueOnce(chain([])); + + await expect( + (await caller()).company.createWithRole(validInput), + ).rejects.toMatchObject({ code: "INTERNAL_SERVER_ERROR" }); + }); + + test("throws when the role insert returns no id", async () => { + db.query.Company.findMany.mockResolvedValue([]); + db.query.Role.findMany.mockResolvedValue([]); + db.insert + .mockReturnValueOnce(chain([{ id: "company-1" }])) + .mockReturnValueOnce(chain([])); + + await expect( + (await caller()).company.createWithRole(validInput), + ).rejects.toMatchObject({ code: "INTERNAL_SERVER_ERROR" }); + }); + }); +}); diff --git a/packages/api/tests/companytoLocation.test.ts b/packages/api/tests/companytoLocation.test.ts new file mode 100644 index 00000000..c32ca3c9 --- /dev/null +++ b/packages/api/tests/companytoLocation.test.ts @@ -0,0 +1,55 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; + +import { appRouter } from "../src/root"; +import { createCallerFactory, createTRPCContext } from "../src/trpc"; +import { chain, studentSession } from "./helpers"; + +const db = vi.hoisted(() => ({ + insert: vi.fn(), + select: vi.fn(), +})); + +vi.mock("@cooper/db/client", () => ({ db })); +vi.mock("@cooper/auth", () => ({ + auth: { api: { getSession: vi.fn() } }, +})); + +async function caller() { + const ctx = await createTRPCContext({ + session: studentSession, + headers: new Headers(), + }); + return createCallerFactory(appRouter)(ctx); +} + +describe("companyToLocation router", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + test("create inserts the join-table values", async () => { + const insertChain = chain([{ companyId: "c1", locationId: "l1" }]); + db.insert.mockReturnValue(insertChain); + + const input = { companyId: "c1", locationId: "l1" }; + await (await caller()).companyToLocation.create(input); + + expect(db.insert).toHaveBeenCalledOnce(); + expect(insertChain.values).toHaveBeenCalledWith(input); + }); + + test("getLocationsByCompanyId selects and joins by company id", async () => { + const rows = [{ companies_to_locations: {}, location: { city: "Boston" } }]; + const selectChain = chain(rows); + db.select.mockReturnValue(selectChain); + + const result = await ( + await caller() + ).companyToLocation.getLocationsByCompanyId({ companyId: "c1" }); + + expect(result).toEqual(rows); + expect(db.select).toHaveBeenCalledOnce(); + expect(selectChain.leftJoin).toHaveBeenCalledOnce(); + expect(selectChain.where).toHaveBeenCalledOnce(); + }); +}); diff --git a/packages/api/tests/fuzzyHelper.test.ts b/packages/api/tests/fuzzyHelper.test.ts new file mode 100644 index 00000000..d58d46ee --- /dev/null +++ b/packages/api/tests/fuzzyHelper.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, test } from "vitest"; + +import { performFuseSearch } from "../src/utils/fuzzyHelper"; + +interface Item { + name: string; +} + +const items: Item[] = [ + { name: "Draft Kings" }, + { name: "Klaviyo" }, + { name: "Hubspot" }, +]; + +describe("performFuseSearch", () => { + test("returns the original list when no query is provided", () => { + expect(performFuseSearch(items, ["name"], undefined)).toBe(items); + }); + + test("returns the original list for an empty query", () => { + expect(performFuseSearch(items, ["name"], "")).toBe(items); + }); + + test("returns fuzzy matches for a query", () => { + const result = performFuseSearch(items, ["name"], "klaviyo"); + expect(result[0]?.name).toBe("Klaviyo"); + }); + + test("matches approximately (typo tolerant)", () => { + const result = performFuseSearch(items, ["name"], "hubspt"); + expect(result.some((i) => i.name === "Hubspot")).toBe(true); + }); +}); diff --git a/packages/api/tests/helpers.ts b/packages/api/tests/helpers.ts new file mode 100644 index 00000000..286149d1 --- /dev/null +++ b/packages/api/tests/helpers.ts @@ -0,0 +1,73 @@ +import { vi } from "vitest"; + +import type { Session } from "@cooper/auth"; + +/** + * A Drizzle query-builder is chainable (`db.select().from().where()...`) and + * thenable (it resolves when awaited). This builds a stand-in: every builder + * method returns the same object, and awaiting it resolves to `result`. + */ +export function chain(result: T) { + const builder: Record = {}; + const methods = [ + "select", + "from", + "leftJoin", + "innerJoin", + "where", + "groupBy", + "having", + "orderBy", + "limit", + "offset", + "values", + "set", + "returning", + "onConflictDoNothing", + "onConflictDoUpdate", + ]; + for (const method of methods) { + builder[method] = vi.fn(() => builder); + } + (builder as { then: unknown }).then = (resolve: (value: T) => unknown) => + resolve(result); + return builder as Record> & PromiseLike; +} + +const baseUser = { + emailVerified: true, + image: null, + createdAt: new Date(), + updatedAt: new Date(), + isDisabled: false, +}; + +const baseSession = { + id: "session-1", + token: "test-token", + expiresAt: new Date(Date.now() + 3600 * 1000), + createdAt: new Date(), + updatedAt: new Date(), +}; + +export const studentSession = { + session: { ...baseSession, userId: "user-1" }, + user: { + ...baseUser, + id: "user-1", + email: "student@husky.neu.edu", + name: "Student User", + role: "STUDENT", + }, +} as Session; + +export const adminSession = { + session: { ...baseSession, userId: "admin-1" }, + user: { + ...baseUser, + id: "admin-1", + email: "admin@husky.neu.edu", + name: "Admin User", + role: "ADMIN", + }, +} as Session; diff --git a/packages/api/tests/index.test.ts b/packages/api/tests/index.test.ts new file mode 100644 index 00000000..fe0a0fbd --- /dev/null +++ b/packages/api/tests/index.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, test, vi } from "vitest"; + +vi.mock("@cooper/db/client", () => ({ db: {} })); +vi.mock("@cooper/auth", () => ({ + auth: { api: { getSession: vi.fn() } }, +})); + +import { appRouter } from "../src/root"; + +describe("appRouter wiring", () => { + test("exposes every expected sub-router", () => { + expect(Object.keys(appRouter)).toEqual( + expect.arrayContaining([ + "admin", + "auth", + "company", + "role", + "profile", + "report", + "review", + "location", + "companyToLocation", + "roleAndCompany", + "tool", + "user", + ]), + ); + }); +}); diff --git a/packages/api/tests/location.test.ts b/packages/api/tests/location.test.ts new file mode 100644 index 00000000..c086e10b --- /dev/null +++ b/packages/api/tests/location.test.ts @@ -0,0 +1,93 @@ +/* eslint-disable @typescript-eslint/no-unsafe-assignment -- vitest matchers like expect.anything() return `any` */ +import { beforeEach, describe, expect, test, vi } from "vitest"; + +import { appRouter } from "../src/root"; +import { createCallerFactory, createTRPCContext } from "../src/trpc"; +import { chain, studentSession } from "./helpers"; + +const db = vi.hoisted(() => ({ + query: { + Location: { findMany: vi.fn(), findFirst: vi.fn() }, + }, + insert: vi.fn(), + select: vi.fn(), +})); + +vi.mock("@cooper/db/client", () => ({ db })); +vi.mock("@cooper/auth", () => ({ + auth: { api: { getSession: vi.fn() } }, +})); + +async function caller() { + const ctx = await createTRPCContext({ + session: studentSession, + headers: new Headers(), + }); + return createCallerFactory(appRouter)(ctx); +} + +describe("location router", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + test("list returns all locations ordered by city", async () => { + const rows = [{ id: "1", city: "Boston" }]; + db.query.Location.findMany.mockResolvedValue(rows); + + const result = await (await caller()).location.list(); + + expect(result).toEqual(rows); + expect(db.query.Location.findMany).toHaveBeenCalledWith({ + orderBy: expect.anything(), + }); + }); + + test("getById queries by the given id", async () => { + const row = { id: "loc-1", city: "Boston" }; + db.query.Location.findFirst.mockResolvedValue(row); + + const result = await (await caller()).location.getById({ id: "loc-1" }); + + expect(result).toEqual(row); + expect(db.query.Location.findFirst).toHaveBeenCalledOnce(); + }); + + test("getByPrefix passes a where predicate and city ordering", async () => { + db.query.Location.findMany.mockResolvedValue([]); + + await (await caller()).location.getByPrefix({ prefix: "Bo" }); + + expect(db.query.Location.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.any(Function), + orderBy: expect.anything(), + }), + ); + }); + + test("create inserts the provided location values", async () => { + const insertChain = chain([{ id: "new" }]); + db.insert.mockReturnValue(insertChain); + + const input = { city: "Boston", state: "MA", country: "USA" }; + await (await caller()).location.create(input); + + expect(db.insert).toHaveBeenCalledOnce(); + expect(insertChain.values).toHaveBeenCalledWith(input); + }); + + test("getByPopularity builds an aggregated, grouped query", async () => { + const rows = [{ id: "1", city: "Boston", companyCount: 3 }]; + db.select.mockReturnValue(chain(rows)); + + const result = await ( + await caller() + ).location.getByPopularity({ + prefix: "Bo", + }); + + expect(result).toEqual(rows); + expect(db.select).toHaveBeenCalledOnce(); + }); +}); diff --git a/packages/api/tests/profile.test.ts b/packages/api/tests/profile.test.ts new file mode 100644 index 00000000..e089d145 --- /dev/null +++ b/packages/api/tests/profile.test.ts @@ -0,0 +1,143 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; + +import { appRouter } from "../src/root"; +import { createCallerFactory, createTRPCContext } from "../src/trpc"; +import { chain, studentSession } from "./helpers"; + +const db = vi.hoisted(() => ({ + query: { Profile: { findMany: vi.fn(), findFirst: vi.fn() } }, + insert: vi.fn(), + delete: vi.fn(), + update: vi.fn(), + select: vi.fn(), + execute: vi.fn(), +})); + +vi.mock("@cooper/db/client", () => ({ db })); +vi.mock("@cooper/auth", () => ({ + auth: { api: { getSession: vi.fn() } }, +})); + +async function caller() { + const ctx = await createTRPCContext({ + session: studentSession, + headers: new Headers(), + }); + return createCallerFactory(appRouter)(ctx); +} + +describe("profile router", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + test("list returns all profiles", async () => { + db.query.Profile.findMany.mockResolvedValue([{ id: "p1" }]); + const result = await (await caller()).profile.list(); + expect(result).toEqual([{ id: "p1" }]); + }); + + test("getById fetches a single profile", async () => { + db.query.Profile.findFirst.mockResolvedValue({ id: "p1" }); + const result = await (await caller()).profile.getById({ id: "p1" }); + expect(result).toEqual({ id: "p1" }); + }); + + test("getCurrentUser looks up the profile for the session user", async () => { + db.query.Profile.findFirst.mockResolvedValue({ + id: "p1", + userId: "user-1", + }); + const result = await (await caller()).profile.getCurrentUser(); + expect(result).toEqual({ id: "p1", userId: "user-1" }); + expect(db.query.Profile.findFirst).toHaveBeenCalledOnce(); + }); + + test("delete removes the profile by id", async () => { + const deleteChain = chain([{ id: "p1" }]); + db.delete.mockReturnValue(deleteChain); + await (await caller()).profile.delete("p1"); + expect(db.delete).toHaveBeenCalledOnce(); + expect(deleteChain.where).toHaveBeenCalledOnce(); + }); + + test("favoriteRole inserts a profile/role pair", async () => { + const insertChain = chain([{}]); + db.insert.mockReturnValue(insertChain); + await ( + await caller() + ).profile.favoriteRole({ + profileId: "p1", + roleId: "r1", + }); + expect(insertChain.values).toHaveBeenCalledWith({ + profileId: "p1", + roleId: "r1", + }); + }); + + test("unfavoriteRole deletes the matching pair", async () => { + const deleteChain = chain([{}]); + db.delete.mockReturnValue(deleteChain); + await ( + await caller() + ).profile.unfavoriteRole({ + profileId: "p1", + roleId: "r1", + }); + expect(db.delete).toHaveBeenCalledOnce(); + expect(deleteChain.where).toHaveBeenCalledOnce(); + }); + + test("favoriteCompany inserts a profile/company pair", async () => { + const insertChain = chain([{}]); + db.insert.mockReturnValue(insertChain); + await ( + await caller() + ).profile.favoriteCompany({ + profileId: "p1", + companyId: "c1", + }); + expect(insertChain.values).toHaveBeenCalledWith({ + profileId: "p1", + companyId: "c1", + }); + }); + + test("listFavoriteCompanies returns the rows from the raw query", async () => { + db.execute.mockResolvedValue({ + rows: [{ profileId: "p1", companyId: "c1" }], + }); + const result = await ( + await caller() + ).profile.listFavoriteCompanies({ + profileId: "p1", + }); + expect(result).toEqual([{ profileId: "p1", companyId: "c1" }]); + }); + + test("listFavoriteRoles returns the rows from the raw query", async () => { + db.execute.mockResolvedValue({ + rows: [{ profileId: "p1", roleId: "r1" }], + }); + const result = await ( + await caller() + ).profile.listFavoriteRoles({ + profileId: "p1", + }); + expect(result).toEqual([{ profileId: "p1", roleId: "r1" }]); + }); + + test("listFavoriteReviews selects from the join table", async () => { + const selectChain = chain([{ profileId: "p1", reviewId: "rev1" }]); + db.select.mockReturnValue(selectChain); + const result = await ( + await caller() + ).profile.listFavoriteReviews({ + profileId: "p1", + }); + expect(result).toEqual([{ profileId: "p1", reviewId: "rev1" }]); + expect(selectChain.from).toHaveBeenCalledOnce(); + expect(selectChain.where).toHaveBeenCalledOnce(); + }); +}); diff --git a/packages/api/tests/report.test.ts b/packages/api/tests/report.test.ts new file mode 100644 index 00000000..8c14c8d4 --- /dev/null +++ b/packages/api/tests/report.test.ts @@ -0,0 +1,78 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; + +import { appRouter } from "../src/root"; +import { createCallerFactory, createTRPCContext } from "../src/trpc"; +import { chain, studentSession } from "./helpers"; + +const db = vi.hoisted(() => ({ + query: { Profile: { findFirst: vi.fn() } }, + insert: vi.fn(), +})); + +vi.mock("@cooper/db/client", () => ({ db })); +vi.mock("@cooper/auth", () => ({ + auth: { api: { getSession: vi.fn() } }, +})); + +async function caller() { + const ctx = await createTRPCContext({ + session: studentSession, + headers: new Headers(), + }); + return createCallerFactory(appRouter)(ctx); +} + +const baseInput = { + entityType: "review" as const, + entityId: "rev-1", + reason: "SPAM", + reportText: "This is spam", +}; + +describe("report router", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + test("throws when the user has no profile", async () => { + db.query.Profile.findFirst.mockResolvedValue(undefined); + + await expect( + (await caller()).report.create(baseInput), + ).rejects.toMatchObject({ code: "PRECONDITION_FAILED" }); + expect(db.insert).not.toHaveBeenCalled(); + }); + + test("creates a review report tied to the user's profile", async () => { + db.query.Profile.findFirst.mockResolvedValue({ id: "profile-1" }); + const insertChain = chain([{ id: "report-1" }]); + db.insert.mockReturnValue(insertChain); + + await (await caller()).report.create(baseInput); + + expect(insertChain.values).toHaveBeenCalledWith({ + profileId: "profile-1", + reason: "SPAM", + reportText: "This is spam", + reviewId: "rev-1", + }); + }); + + test("maps the entity type to the matching id column", async () => { + db.query.Profile.findFirst.mockResolvedValue({ id: "profile-1" }); + const insertChain = chain([{ id: "report-1" }]); + db.insert.mockReturnValue(insertChain); + + await ( + await caller() + ).report.create({ + ...baseInput, + entityType: "company", + entityId: "comp-1", + }); + + expect(insertChain.values).toHaveBeenCalledWith( + expect.objectContaining({ companyId: "comp-1" }), + ); + }); +}); diff --git a/packages/api/tests/review-extra.test.ts b/packages/api/tests/review-extra.test.ts new file mode 100644 index 00000000..0259ea78 --- /dev/null +++ b/packages/api/tests/review-extra.test.ts @@ -0,0 +1,114 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; + +import { appRouter } from "../src/root"; +import { createCallerFactory, createTRPCContext } from "../src/trpc"; +import { chain, studentSession } from "./helpers"; + +const db = vi.hoisted(() => ({ + query: { + Review: { findMany: vi.fn(), findFirst: vi.fn() }, + Company: { findMany: vi.fn() }, + }, + delete: vi.fn(), +})); + +vi.mock("@cooper/db/client", () => ({ db })); +vi.mock("@cooper/auth", () => ({ + auth: { api: { getSession: vi.fn() } }, +})); + +async function caller() { + const ctx = await createTRPCContext({ + session: studentSession, + headers: new Headers(), + }); + return createCallerFactory(appRouter)(ctx); +} + +describe("review router (read + aggregate endpoints)", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + test("getByRole queries published reviews for a role", async () => { + db.query.Review.findMany.mockResolvedValue([{ id: "rev1" }]); + const result = await (await caller()).review.getByRole({ id: "r1" }); + expect(result).toEqual([{ id: "rev1" }]); + }); + + test("getByCompany queries published reviews for a company", async () => { + db.query.Review.findMany.mockResolvedValue([{ id: "rev1" }]); + const result = await (await caller()).review.getByCompany({ id: "c1" }); + expect(result).toEqual([{ id: "rev1" }]); + }); + + test("getByProfile queries non-hidden reviews for a profile", async () => { + db.query.Review.findMany.mockResolvedValue([{ id: "rev1" }]); + const result = await (await caller()).review.getByProfile({ id: "p1" }); + expect(result).toEqual([{ id: "rev1" }]); + }); + + test("getById fetches a single review with relations", async () => { + db.query.Review.findFirst.mockResolvedValue({ id: "rev1" }); + const result = await (await caller()).review.getById({ id: "rev1" }); + expect(result).toEqual({ id: "rev1" }); + }); + + test("delete removes a review by id", async () => { + const deleteChain = chain([{ id: "rev1" }]); + db.delete.mockReturnValue(deleteChain); + await (await caller()).review.delete("rev1"); + expect(db.delete).toHaveBeenCalledOnce(); + expect(deleteChain.where).toHaveBeenCalledOnce(); + }); + + test("getPayDataGlobal computes average pay and a distribution", async () => { + db.query.Review.findMany.mockResolvedValue([ + { hourlyPay: "20" }, + { hourlyPay: "30" }, + { hourlyPay: "0" }, // excluded — not > 0 + ]); + const result = await (await caller()).review.getPayDataGlobal(); + expect(result.totalReviews).toBe(2); + expect(result.averageHourlyPay).toBe(25); + const bucket2030 = result.payDistribution.find((b) => b.label === "$20-30"); + expect(bucket2030?.count).toBe(1); + }); + + test("getPayDataByIndustry filters by the industry's companies", async () => { + db.query.Company.findMany.mockResolvedValue([{ id: "c1" }]); + db.query.Review.findMany.mockResolvedValue([{ hourlyPay: "40" }]); + const result = await ( + await caller() + ).review.getPayDataByIndustry({ industry: "TECHNOLOGY" }); + expect(result.totalReviews).toBe(1); + expect(result.averageHourlyPay).toBe(40); + }); + + test("getInterviewDataGlobal reports the rounds mode and distribution", async () => { + db.query.Review.findMany.mockResolvedValue([ + { interviewRounds: [{}, {}] }, + { interviewRounds: [{}, {}] }, + { interviewRounds: [{}, {}, {}] }, + { interviewRounds: [] }, // excluded — no rounds + ]); + const result = await (await caller()).review.getInterviewDataGlobal(); + expect(result.roundsMode).toBe(2); + expect(result.roundsDistribution).toEqual([ + { rounds: 2, count: 2 }, + { rounds: 3, count: 1 }, + ]); + }); + + test("getInterviewDataByIndustry aggregates rounds across the industry", async () => { + db.query.Company.findMany.mockResolvedValue([{ id: "c1" }]); + db.query.Review.findMany.mockResolvedValue([ + { interviewRounds: [{}, {}] }, + { interviewRounds: [{}, {}] }, + ]); + const result = await ( + await caller() + ).review.getInterviewDataByIndustry({ industry: "TECHNOLOGY" }); + expect(result.roundsMode).toBe(2); + }); +}); diff --git a/packages/api/tests/review-mutations.test.ts b/packages/api/tests/review-mutations.test.ts new file mode 100644 index 00000000..995a0278 --- /dev/null +++ b/packages/api/tests/review-mutations.test.ts @@ -0,0 +1,273 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; + +import { Status } from "@cooper/db/schema"; + +import { appRouter } from "../src/root"; +import { createCallerFactory, createTRPCContext } from "../src/trpc"; +import { chain, studentSession } from "./helpers"; + +const db = vi.hoisted(() => ({ + query: { + Review: { findMany: vi.fn(), findFirst: vi.fn() }, + Company: { findMany: vi.fn() }, + CompaniesToLocations: { findFirst: vi.fn() }, + }, + insert: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + select: vi.fn(), +})); + +vi.mock("@cooper/db/client", () => ({ db })); +vi.mock("@cooper/auth", () => ({ + auth: { api: { getSession: vi.fn() } }, +})); + +async function caller(res?: Response) { + const ctx = await createTRPCContext({ + session: studentSession, + headers: new Headers(), + res, + }); + return createCallerFactory(appRouter)(ctx); +} + +const baseReviewInput = { + profileId: "p1", + status: Status.PUBLISHED, + companyId: "c1", + locationId: "l1", + roleId: "r1", + textReview: "A perfectly clean review", + workTerm: "SPRING" as const, + workYear: 2024, + overallRating: 4, + cultureRating: 4, + supervisorRating: 4, + hourlyPay: "25", +}; + +describe("review router mutations", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + test("list applies fuzzy search over the fetched reviews", async () => { + db.query.Review.findMany.mockResolvedValue([ + { id: "1", reviewHeadline: "Amazing internship", textReview: "" }, + { id: "2", reviewHeadline: "Terrible time", textReview: "" }, + ]); + + const result = await (await caller()).review.list({ search: "Amazing" }); + + expect(result).toEqual([ + { id: "1", reviewHeadline: "Amazing internship", textReview: "" }, + ]); + }); + + test("create rejects when there is no profileId", async () => { + await expect( + (await caller()).review.create({ + ...baseReviewInput, + profileId: "", + }), + ).rejects.toMatchObject({ code: "FORBIDDEN" }); + }); + + test("create rejects profane review text", async () => { + await expect( + (await caller()).review.create({ + ...baseReviewInput, + textReview: "this is shit", + }), + ).rejects.toMatchObject({ code: "PRECONDITION_FAILED" }); + }); + + test("create rejects a 6th published review", async () => { + db.query.Review.findMany.mockResolvedValue( + Array.from({ length: 5 }, (_, i) => ({ + id: String(i), + status: Status.PUBLISHED, + workTerm: "FALL", + workYear: 2020, + })), + ); + + await expect( + (await caller()).review.create(baseReviewInput as never), + ).rejects.toMatchObject({ code: "PRECONDITION_FAILED" }); + }); + + test("create rejects a 3rd review in the same cycle", async () => { + db.query.Review.findMany.mockResolvedValue([ + { id: "a", status: Status.DRAFT, workTerm: "SPRING", workYear: 2024 }, + { id: "b", status: Status.DRAFT, workTerm: "SPRING", workYear: 2024 }, + ]); + + await expect( + (await caller()).review.create(baseReviewInput as never), + ).rejects.toMatchObject({ code: "PRECONDITION_FAILED" }); + }); + + test("create inserts the review, rounds, location link and a new tool", async () => { + db.query.Review.findMany.mockResolvedValue([]); + db.query.CompaniesToLocations.findFirst.mockResolvedValue(undefined); + db.insert.mockReturnValue(chain([{ id: "rev1" }])); + // First lookup misses (tool absent), second hits after insert. + db.select + .mockReturnValueOnce(chain([])) + .mockReturnValueOnce(chain([{ id: "t1" }])); + + await ( + await caller() + ).review.create({ + ...baseReviewInput, + interviewRounds: [ + { interviewType: "technical", interviewDifficulty: "hard" }, + { interviewType: null, interviewDifficulty: null }, // skipped + ], + toolNames: ["React", " "], // blank entry is skipped + } as never); + + // location link + review + interview rounds + tool + reviewsToTools + expect(db.insert).toHaveBeenCalled(); + expect(db.select).toHaveBeenCalledTimes(2); + }); + + test("create reuses an existing CompaniesToLocations link", async () => { + db.query.Review.findMany.mockResolvedValue([]); + db.query.CompaniesToLocations.findFirst.mockResolvedValue({ id: "ctl1" }); + db.insert.mockReturnValue(chain([{ id: "rev1" }])); + + await ( + await caller() + ).review.create({ + ...baseReviewInput, + interviewRounds: [], + toolNames: [], + }); + + expect(db.query.CompaniesToLocations.findFirst).toHaveBeenCalledOnce(); + }); + + test("saveDraft rejects a duplicate draft", async () => { + db.query.Review.findFirst.mockResolvedValue({ id: "dup" }); + + await expect( + (await caller()).review.saveDraft(baseReviewInput as never), + ).rejects.toMatchObject({ code: "CONFLICT" }); + }); + + test("saveDraft inserts a new draft and returns its id", async () => { + db.query.Review.findFirst.mockResolvedValue(undefined); + db.insert.mockReturnValue(chain([{ id: "draft1" }])); + // Tool missing on first lookup, present after it is inserted. + db.select + .mockReturnValueOnce(chain([])) + .mockReturnValueOnce(chain([{ id: "t1" }])); + + const result = await ( + await caller() + ).review.saveDraft({ + ...baseReviewInput, + interviewRounds: [ + { interviewType: "behavioral", interviewDifficulty: "easy" }, + ], + toolNames: ["Figma"], + } as never); + + expect(result).toEqual({ id: "draft1" }); + }); + + test("saveDraft rejects when there is no profileId", async () => { + await expect( + (await caller()).review.saveDraft({ + ...baseReviewInput, + profileId: "", + }), + ).rejects.toMatchObject({ code: "FORBIDDEN" }); + }); + + test("update clears prior rounds/tools and re-inserts them", async () => { + db.delete.mockReturnValue(chain([])); + db.update.mockReturnValue(chain([])); + db.insert.mockReturnValue(chain([{ id: "rev1" }])); + db.select + .mockReturnValueOnce(chain([])) // tool missing + .mockReturnValueOnce(chain([{ id: "t1" }])); // found after insert + + await ( + await caller() + ).review.update({ + ...baseReviewInput, + id: "rev1", + interviewRounds: [ + { interviewType: "screening", interviewDifficulty: "average" }, + ], + toolNames: ["Python"], + } as never); + + expect(db.delete).toHaveBeenCalledTimes(2); + expect(db.update).toHaveBeenCalledOnce(); + }); + + test("getInterviewDataByIndustry sets cache headers when a Response exists", async () => { + const res = { headers: new Headers() } as Response; + db.query.Company.findMany.mockResolvedValue([{ id: "c1" }]); + db.query.Review.findMany.mockResolvedValue([{ interviewRounds: [{}, {}] }]); + + await ( + await caller(res) + ).review.getInterviewDataByIndustry({ + industry: "TECHNOLOGY", + }); + + expect(res.headers.get("Cache-Control")).toContain("max-age=28800"); + }); + + test("getInterviewDataGlobal sets cache headers when a Response exists", async () => { + const res = { headers: new Headers() } as Response; + db.query.Review.findMany.mockResolvedValue([{ interviewRounds: [{}] }]); + + await (await caller(res)).review.getInterviewDataGlobal(); + + expect(res.headers.get("Cache-Control")).toContain("max-age=28800"); + }); + + test("getPayDataGlobal sets cache headers when a Response exists", async () => { + const res = { headers: new Headers() } as Response; + db.query.Review.findMany.mockResolvedValue([{ hourlyPay: "20" }]); + + await (await caller(res)).review.getPayDataGlobal(); + + expect(res.headers.get("Cache-Control")).toContain("max-age=28800"); + }); + + test("getPayDataByIndustry sets cache headers when a Response exists", async () => { + const res = { headers: new Headers() } as Response; + db.query.Company.findMany.mockResolvedValue([{ id: "c1" }]); + db.query.Review.findMany.mockResolvedValue([{ hourlyPay: "20" }]); + + await ( + await caller(res) + ).review.getPayDataByIndustry({ + industry: "TECHNOLOGY", + }); + + expect(res.headers.get("Cache-Control")).toContain("max-age=28800"); + }); + + test("getAverageByIndustry sets cache headers when a Response exists", async () => { + const res = { headers: new Headers() } as Response; + db.query.Company.findMany.mockResolvedValue([{ id: "c1" }]); + db.query.Review.findMany.mockResolvedValue([]); + + await ( + await caller(res) + ).review.getAverageByIndustry({ + industry: "TECHNOLOGY", + }); + + expect(res.headers.get("Cache-Control")).toContain("max-age=28800"); + }); +}); diff --git a/packages/api/tests/role-extra.test.ts b/packages/api/tests/role-extra.test.ts new file mode 100644 index 00000000..831ee2be --- /dev/null +++ b/packages/api/tests/role-extra.test.ts @@ -0,0 +1,269 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; + +import { appRouter } from "../src/root"; +import { createCallerFactory, createTRPCContext } from "../src/trpc"; +import { studentSession } from "./helpers"; + +const db = vi.hoisted(() => ({ + query: { + Role: { findFirst: vi.fn(), findMany: vi.fn() }, + Company: { findFirst: vi.fn(), findMany: vi.fn() }, + Review: { findMany: vi.fn() }, + }, + execute: vi.fn(), +})); + +vi.mock("@cooper/db/client", () => ({ db })); +vi.mock("@cooper/auth", () => ({ + auth: { api: { getSession: vi.fn() } }, +})); + +async function callList(input: Record) { + const ctx = await createTRPCContext({ + session: studentSession, + headers: new Headers(), + }); + return createCallerFactory(appRouter)(ctx).role.list(input); +} + +describe("role router additional coverage", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + test("list returns empty when no roles have reviews", async () => { + db.execute.mockResolvedValue({ rows: [] }); + db.query.Company.findMany.mockResolvedValue([]); + + const result = await callList({}); + + expect(result).toEqual({ roles: [], totalCount: 0 }); + }); + + test("list joins roles with their companies and paginates", async () => { + db.execute.mockResolvedValue({ + rows: [{ role_id: "r1" }, { role_id: "r2" }], + }); + db.query.Role.findMany.mockResolvedValue([ + { + id: "r1", + companyId: "c1", + title: "Software Engineer", + description: "", + }, + { id: "r2", companyId: "c2", title: "Data Analyst", description: "" }, + ]); + db.query.Company.findMany.mockResolvedValue([{ id: "c1", name: "Acme" }]); + + const result = await callList({ limit: 1, offset: 0 }); + + expect(result.totalCount).toBe(2); + expect(result.roles).toHaveLength(1); + expect(result.roles[0]).toMatchObject({ companyName: "Acme" }); + }); + + test("list with sortBy rating uses the avg-rating query", async () => { + db.execute.mockResolvedValue({ + rows: [{ id: "r1", companyId: "c1", title: "SWE", avg_rating: 5 }], + }); + db.query.Company.findMany.mockResolvedValue([{ id: "c1", name: "Acme" }]); + + const ctx = await createTRPCContext({ + session: studentSession, + headers: new Headers(), + }); + const result = await createCallerFactory(appRouter)(ctx).role.list({ + sortBy: "rating", + } as never); + + expect(result.totalCount).toBe(1); + expect(db.execute).toHaveBeenCalledOnce(); + }); + + test("create rejects a profane description", async () => { + await expect( + callerCreate({ + title: "Software Engineer", + description: "this shit role", + companyId: "c1", + createdBy: "p1", + }), + ).rejects.toMatchObject({ code: "PRECONDITION_FAILED" }); + }); + + test("getByCompany returns all non-hidden roles by default", async () => { + db.query.Role.findMany.mockResolvedValue([{ id: "r1" }]); + + const ctx = await createTRPCContext({ + session: studentSession, + headers: new Headers(), + }); + const result = await createCallerFactory(appRouter)(ctx).role.getByCompany({ + companyId: "c1", + }); + + expect(result).toEqual([{ id: "r1" }]); + }); + + test("getByCompany with onlyWithReviews returns [] when none have reviews", async () => { + db.execute.mockResolvedValue({ rows: [] }); + + const ctx = await createTRPCContext({ + session: studentSession, + headers: new Headers(), + }); + const result = await createCallerFactory(appRouter)(ctx).role.getByCompany({ + companyId: "c1", + onlyWithReviews: true, + }); + + expect(result).toEqual([]); + }); + + test("getByCompany with onlyWithReviews queries roles that have reviews", async () => { + db.execute.mockResolvedValue({ rows: [{ role_id: "r1" }] }); + db.query.Role.findMany.mockResolvedValue([{ id: "r1" }]); + + const ctx = await createTRPCContext({ + session: studentSession, + headers: new Headers(), + }); + const result = await createCallerFactory(appRouter)(ctx).role.getByCompany({ + companyId: "c1", + onlyWithReviews: true, + }); + + expect(result).toEqual([{ id: "r1" }]); + }); + + test("getByCreatedBy queries non-hidden roles for a profile", async () => { + db.query.Role.findMany.mockResolvedValue([{ id: "r1" }]); + + const ctx = await createTRPCContext({ + session: studentSession, + headers: new Headers(), + }); + const result = await createCallerFactory(appRouter)( + ctx, + ).role.getByCreatedBy({ createdBy: "p1" }); + + expect(result).toEqual([{ id: "r1" }]); + }); + + test("getInterviewDataById aggregates round counts, types and difficulty", async () => { + db.query.Review.findMany.mockResolvedValue([ + { + id: "rev1", + companyId: "c1", + interviewRounds: [ + { interviewType: "technical", interviewDifficulty: "hard" }, + { interviewType: "behavioral", interviewDifficulty: "easy" }, + ], + }, + { + id: "rev2", + companyId: "c1", + interviewRounds: [ + { interviewType: "technical", interviewDifficulty: "hard" }, + ], + }, + { id: "rev3", companyId: "c1", interviewRounds: [] }, // excluded + ]); + db.query.Company.findFirst.mockResolvedValue({ industry: "TECHNOLOGY" }); + + const ctx = await createTRPCContext({ + session: studentSession, + headers: new Headers(), + }); + const result = await createCallerFactory(appRouter)( + ctx, + ).role.getInterviewDataById({ roleId: "r1" }); + + expect(result.totalReviewsWithRounds).toBe(2); + expect(result.industryName).toBe("TECHNOLOGY"); + expect(result.overallDominantDifficulty).toBe("hard"); + const technical = result.types.find((t) => t.type === "technical"); + expect(technical?.reviewCount).toBe(2); + expect(technical?.dominantDifficulty).toBe("hard"); + }); + + test("getInterviewDataById returns nulls when there are no reviews", async () => { + db.query.Review.findMany.mockResolvedValue([]); + + const ctx = await createTRPCContext({ + session: studentSession, + headers: new Headers(), + }); + const result = await createCallerFactory(appRouter)( + ctx, + ).role.getInterviewDataById({ roleId: "r1" }); + + expect(result.totalReviewsWithRounds).toBe(0); + expect(result.roundsMode).toBeNull(); + expect(result.industryName).toBeNull(); + expect(result.overallDominantDifficulty).toBeNull(); + }); + + test("getAverageById reports a two-way work environment tie", async () => { + db.query.Review.findMany.mockResolvedValue([ + { workEnvironment: "REMOTE", reviewsToTools: [] }, + { workEnvironment: "HYBRID", reviewsToTools: [] }, + ]); + + const ctx = await createTRPCContext({ + session: studentSession, + headers: new Headers(), + }); + const result = await createCallerFactory(appRouter)( + ctx, + ).role.getAverageById({ roleId: "r1" }); + + expect(result.workEnvironmentMode).toBe("Remote and Hybrid"); + }); + + test("getAverageById reports a three-way tie and alert breakdown", async () => { + db.query.Review.findMany.mockResolvedValue([ + { workEnvironment: "REMOTE", reviewsToTools: [] }, + { workEnvironment: "HYBRID", reviewsToTools: [] }, + { workEnvironment: "INPERSON", reviewsToTools: [] }, + ]); + + const ctx = await createTRPCContext({ + session: studentSession, + headers: new Headers(), + }); + const result = await createCallerFactory(appRouter)( + ctx, + ).role.getAverageById({ roleId: "r1" }); + + expect(result.workEnvironmentMode).toBe("Remote, Hybrid, and In-Person"); + expect(result.workEnvironmentAlerts).toEqual([]); + }); + + test("getAverageById surfaces minority work environments as alerts", async () => { + db.query.Review.findMany.mockResolvedValue([ + { workEnvironment: "REMOTE", reviewsToTools: [] }, + { workEnvironment: "REMOTE", reviewsToTools: [] }, + { workEnvironment: "HYBRID", reviewsToTools: [] }, + ]); + + const ctx = await createTRPCContext({ + session: studentSession, + headers: new Headers(), + }); + const result = await createCallerFactory(appRouter)( + ctx, + ).role.getAverageById({ roleId: "r1" }); + + expect(result.workEnvironmentMode).toBe("Remote"); + expect(result.workEnvironmentAlerts).toEqual(["One reported Hybrid"]); + }); +}); + +async function callerCreate(input: Record) { + const ctx = await createTRPCContext({ + session: studentSession, + headers: new Headers(), + }); + return createCallerFactory(appRouter)(ctx).role.create(input as never); +} diff --git a/packages/api/tests/role.test.ts b/packages/api/tests/role.test.ts new file mode 100644 index 00000000..4dcfd97d --- /dev/null +++ b/packages/api/tests/role.test.ts @@ -0,0 +1,191 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; + +import { appRouter } from "../src/root"; +import { createCallerFactory, createTRPCContext } from "../src/trpc"; +import { chain, studentSession } from "./helpers"; + +const db = vi.hoisted(() => ({ + query: { + Role: { findFirst: vi.fn(), findMany: vi.fn() }, + Company: { findFirst: vi.fn(), findMany: vi.fn() }, + Review: { findMany: vi.fn() }, + }, + insert: vi.fn(), + execute: vi.fn(), +})); + +vi.mock("@cooper/db/client", () => ({ db })); +vi.mock("@cooper/auth", () => ({ + auth: { api: { getSession: vi.fn() } }, +})); + +async function caller() { + const ctx = await createTRPCContext({ + session: studentSession, + headers: new Headers(), + }); + return createCallerFactory(appRouter)(ctx); +} + +describe("role router", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + test("getById returns a role by id", async () => { + db.query.Role.findFirst.mockResolvedValue({ id: "r1", title: "SWE" }); + const result = await (await caller()).role.getById({ id: "r1" }); + expect(result).toEqual({ id: "r1", title: "SWE" }); + }); + + test("getByIdWithCompany returns null when the role is missing", async () => { + db.query.Role.findFirst.mockResolvedValue(undefined); + const result = await (await caller()).role.getByIdWithCompany({ id: "r1" }); + expect(result).toBeNull(); + }); + + test("getByIdWithCompany merges company name and slug", async () => { + db.query.Role.findFirst.mockResolvedValue({ + id: "r1", + title: "SWE", + companyId: "c1", + }); + db.query.Company.findFirst.mockResolvedValue({ + name: "Acme", + slug: "acme", + }); + const result = await (await caller()).role.getByIdWithCompany({ id: "r1" }); + expect(result).toMatchObject({ + id: "r1", + type: "role", + companyName: "Acme", + companySlug: "acme", + }); + }); + + test("getByCompanySlugAndRoleSlug returns null when the company is missing", async () => { + db.query.Company.findFirst.mockResolvedValue(undefined); + const result = await ( + await caller() + ).role.getByCompanySlugAndRoleSlug({ + companySlug: "acme", + roleSlug: "swe", + }); + expect(result).toBeNull(); + }); + + test("getByCompanySlugAndRoleSlug returns the role with company info", async () => { + db.query.Company.findFirst.mockResolvedValue({ + id: "c1", + name: "Acme", + slug: "acme", + }); + db.query.Role.findFirst.mockResolvedValue({ id: "r1", title: "SWE" }); + const result = await ( + await caller() + ).role.getByCompanySlugAndRoleSlug({ + companySlug: "acme", + roleSlug: "swe", + }); + expect(result).toMatchObject({ + id: "r1", + companyName: "Acme", + companySlug: "acme", + }); + }); + + test("getManyByIds queries the requested ids", async () => { + db.query.Role.findMany.mockResolvedValue([{ id: "r1" }, { id: "r2" }]); + const result = await ( + await caller() + ).role.getManyByIds({ + ids: ["r1", "r2"], + }); + expect(result).toHaveLength(2); + }); + + test("create rejects profane titles", async () => { + await expect( + (await caller()).role.create({ + title: "Shit Engineer", + description: "A normal role description", + companyId: "c1", + createdBy: "p1", + }), + ).rejects.toMatchObject({ code: "PRECONDITION_FAILED" }); + }); + + test("create inserts a slugged role when the input is clean", async () => { + db.query.Role.findMany.mockResolvedValue([]); + const insertChain = chain([{ id: "r1" }]); + db.insert.mockReturnValue(insertChain); + + await ( + await caller() + ).role.create({ + title: "Software Engineer", + description: "A normal role description", + companyId: "c1", + createdBy: "p1", + }); + + expect(db.insert).toHaveBeenCalledOnce(); + expect(insertChain.values).toHaveBeenCalledWith( + expect.objectContaining({ slug: expect.any(String) }), + ); + }); + + test("getAverageById returns zeros for a role with no reviews", async () => { + db.query.Review.findMany.mockResolvedValue([]); + const result = await (await caller()).role.getAverageById({ roleId: "r1" }); + expect(result.totalReviews).toBe(0); + expect(result.averageOverallRating).toBe(0); + expect(result.tools).toEqual([]); + expect(result.workEnvironmentMode).toBeNull(); + }); + + test("getAverageById aggregates ratings, percentages, and work model", async () => { + db.query.Review.findMany.mockResolvedValue([ + { + overallRating: 4, + hourlyPay: "20", + cultureRating: 5, + supervisorRating: 4, + federalHolidays: true, + pto: true, + workEnvironment: "REMOTE", + jobLength: 4, + workHours: 40, + overtimeNormal: true, + reviewsToTools: [{ tool: { name: "React" } }], + }, + { + overallRating: 2, + hourlyPay: "30", + cultureRating: 3, + supervisorRating: 2, + federalHolidays: false, + pto: true, + workEnvironment: "REMOTE", + jobLength: 6, + workHours: 35, + overtimeNormal: false, + reviewsToTools: [{ tool: { name: "Figma" } }], + }, + ]); + + const result = await (await caller()).role.getAverageById({ roleId: "r1" }); + + expect(result.totalReviews).toBe(2); + expect(result.averageOverallRating).toBe(3); + expect(result.averageHourlyPay).toBe(25); + expect(result.pto).toBe(1); + expect(result.federalHolidays).toBe(0.5); + expect(result.minPay).toBe(20); + expect(result.maxPay).toBe(30); + expect(result.workEnvironmentMode).toBe("Remote"); + expect(result.jobLengthMin).toBe(4); + expect(result.jobLengthMax).toBe(6); + expect(result.tools.sort()).toEqual(["Figma", "React"]); + }); +}); diff --git a/packages/api/tests/roleAndCompany-list.test.ts b/packages/api/tests/roleAndCompany-list.test.ts new file mode 100644 index 00000000..ec6503e8 --- /dev/null +++ b/packages/api/tests/roleAndCompany-list.test.ts @@ -0,0 +1,223 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; + +import { appRouter } from "../src/root"; +import { createCallerFactory, createTRPCContext } from "../src/trpc"; +import { studentSession } from "./helpers"; + +const db = vi.hoisted(() => ({ + query: { + Role: { findMany: vi.fn() }, + Company: { findMany: vi.fn() }, + Review: { findMany: vi.fn() }, + CompaniesToLocations: { findMany: vi.fn() }, + }, + execute: vi.fn(), +})); + +vi.mock("@cooper/db/client", () => ({ db })); +vi.mock("@cooper/auth", () => ({ + auth: { api: { getSession: vi.fn() } }, +})); + +async function list(input: Record) { + const ctx = await createTRPCContext({ + session: studentSession, + headers: new Headers(), + }); + return createCallerFactory(appRouter)(ctx).roleAndCompany.list(input); +} + +const role = { + id: "r1", + companyId: "c1", + title: "Software Engineer", + description: "Build things", +}; +const company = { + id: "c1", + name: "Acme", + industry: "TECHNOLOGY", + description: "A company", + hidden: false, +}; + +/** + * The default (non-rating) list path issues, in order: + * 1. execute -> roles-with-reviews + * 2. Role.findMany + * 3. Company.findMany + * 4. execute -> companies-with-reviews + * then up to 5 aggregate `execute` queries per roles/companies block. + * We seed the first two `execute` calls explicitly and let the rest return + * aggregate rows that populate the avg/rating/work-model maps. + */ +function seedDefaultPath() { + db.query.Role.findMany.mockResolvedValue([role]); + db.query.Company.findMany.mockResolvedValue([company]); + db.execute + .mockResolvedValueOnce({ rows: [{ role_id: "r1" }] }) + .mockResolvedValueOnce({ rows: [{ company_id: "c1" }] }) + .mockResolvedValue({ + rows: [ + { + id: "r1", + avg_hourly_pay: 25, + avg_rating: 4, + avg_culture_rating: 4, + overtime_percent: 0.6, + work_models: ["REMOTE", null], + }, + { + id: "c1", + avg_hourly_pay: 30, + avg_rating: 5, + avg_culture_rating: 5, + overtime_percent: 0.7, + work_models: ["REMOTE"], + }, + ], + }); +} + +describe("roleAndCompany.list", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + test("returns roles and companies with counts", async () => { + seedDefaultPath(); + + const result = await list({}); + + expect(result.totalCount).toBe(2); + expect(result.totalRolesCount).toBe(1); + expect(result.totalCompanyCount).toBe(1); + const roleItem = result.items.find((i) => i.type === "role"); + expect(roleItem).toMatchObject({ companyName: "Acme" }); + }); + + test("type=roles returns only roles", async () => { + seedDefaultPath(); + + const result = await list({ type: "roles" }); + + expect(result.items.every((i) => i.type === "role")).toBe(true); + }); + + test("type=companies returns only companies", async () => { + seedDefaultPath(); + + const result = await list({ type: "companies" }); + + expect(result.items.every((i) => i.type === "company")).toBe(true); + }); + + test("search reorders the combined list and still filters", async () => { + seedDefaultPath(); + + const result = await list({ search: "Acme" }); + + expect(result.items.some((i) => i.type === "company")).toBe(true); + }); + + test("rating sort uses the aggregate ranking queries", async () => { + db.execute + .mockResolvedValueOnce({ + rows: [{ id: "r1", companyId: "c1", title: "SWE", avg_rating: 5 }], + }) // roles-with-ratings + .mockResolvedValueOnce({ + rows: [{ id: "c1", name: "Acme", avg_rating: 5 }], + }) // companies-with-ratings + .mockResolvedValue({ rows: [] }); + + const result = await list({ sortBy: "rating" }); + + expect(result.totalCount).toBe(2); + }); + + test("industry filter keeps matching items and drops the rest", async () => { + seedDefaultPath(); + + const result = await list({ filters: { industries: ["TECHNOLOGY"] } }); + + expect(result.totalCompanyCount).toBe(1); + + db.execute.mockReset(); + seedDefaultPath(); + const none = await list({ filters: { industries: ["FINANCE"] } }); + expect(none.totalCount).toBe(0); + }); + + test("pay, ratings, overtime, culture and work-model filters run together", async () => { + seedDefaultPath(); + + const result = await list({ + filters: { + minPay: 10, + maxPay: 100, + ratings: ["4"], + companyCulture: ["3", "4"], + overtimeWork: true, + workModels: ["REMOTE"], + }, + }); + + expect(result.items.some((i) => i.type === "role")).toBe(true); + }); + + test("location filter consults the CompaniesToLocations map", async () => { + seedDefaultPath(); + db.query.CompaniesToLocations.findMany.mockResolvedValue([ + { companyId: "c1", locationId: "l1" }, + ]); + + const result = await list({ filters: { locations: ["l1"] } }); + + expect(db.query.CompaniesToLocations.findMany).toHaveBeenCalledOnce(); + expect(result.totalCount).toBeGreaterThan(0); + }); + + test("job-type filter consults review job types", async () => { + seedDefaultPath(); + db.query.Review.findMany.mockResolvedValue([ + { companyId: "c1", roleId: "r1", jobType: "Co-op" }, + ]); + + const result = await list({ filters: { jobTypes: ["CO-OP"] } }); + + expect(db.query.Review.findMany).toHaveBeenCalledOnce(); + expect(result.items.some((i) => i.type === "role")).toBe(true); + }); +}); + +describe("roleAndCompany.getPageNumber (rating sort)", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + test("locates an item when sorting by rating", async () => { + db.execute + .mockResolvedValueOnce({ + rows: [{ id: "r1", companyId: "c1", title: "SWE", avg_rating: 5 }], + }) + .mockResolvedValueOnce({ + rows: [{ id: "c1", name: "Acme", avg_rating: 5 }], + }); + + const ctx = await createTRPCContext({ + session: studentSession, + headers: new Headers(), + }); + const result = await createCallerFactory(appRouter)( + ctx, + ).roleAndCompany.getPageNumber({ + itemId: "r1", + itemType: "role", + sortBy: "rating", + type: "all", + limit: 10, + }); + + expect(result).toEqual({ page: 1, found: true }); + }); +}); diff --git a/packages/api/tests/roleAndCompany.test.ts b/packages/api/tests/roleAndCompany.test.ts new file mode 100644 index 00000000..9b224cda --- /dev/null +++ b/packages/api/tests/roleAndCompany.test.ts @@ -0,0 +1,76 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; + +import { appRouter } from "../src/root"; +import { createCallerFactory, createTRPCContext } from "../src/trpc"; +import { studentSession } from "./helpers"; + +const db = vi.hoisted(() => ({ + query: { + Role: { findMany: vi.fn() }, + Company: { findMany: vi.fn() }, + }, + execute: vi.fn(), +})); + +vi.mock("@cooper/db/client", () => ({ db })); +vi.mock("@cooper/auth", () => ({ + auth: { api: { getSession: vi.fn() } }, +})); + +async function caller() { + const ctx = await createTRPCContext({ + session: studentSession, + headers: new Headers(), + }); + return createCallerFactory(appRouter)(ctx); +} + +const baseInput = { + itemType: "role" as const, + sortBy: "default" as const, + type: "all" as const, + limit: 10, +}; + +describe("roleAndCompany.getPageNumber", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + test("returns found:false when the item is not in the list", async () => { + // No roles/companies with reviews. + db.execute.mockResolvedValue({ rows: [] }); + db.query.Role.findMany.mockResolvedValue([]); + db.query.Company.findMany.mockResolvedValue([]); + + const result = await ( + await caller() + ).roleAndCompany.getPageNumber({ + ...baseInput, + itemId: "missing", + }); + + expect(result).toEqual({ page: 1, found: false }); + }); + + test("returns the 1-indexed page when the item is found", async () => { + db.execute + .mockResolvedValueOnce({ rows: [{ role_id: "r1" }] }) // roles with reviews + .mockResolvedValueOnce({ rows: [{ company_id: "c1" }] }); // companies with reviews + db.query.Role.findMany.mockResolvedValue([ + { id: "r1", companyId: "c1", title: "SWE", description: "" }, + ]); + db.query.Company.findMany.mockResolvedValue([ + { id: "c1", name: "Acme", description: "" }, + ]); + + const result = await ( + await caller() + ).roleAndCompany.getPageNumber({ + ...baseInput, + itemId: "r1", + }); + + expect(result).toEqual({ page: 1, found: true }); + }); +}); diff --git a/packages/api/tests/slugHelpers.test.ts b/packages/api/tests/slugHelpers.test.ts new file mode 100644 index 00000000..49bcc230 --- /dev/null +++ b/packages/api/tests/slugHelpers.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, test } from "vitest"; + +import { createSlug, generateUniqueSlug } from "../src/utils/slugHelpers"; + +describe("createSlug", () => { + test("lower-cases and hyphenates spaces", () => { + expect(createSlug("Draft Kings")).toBe("draft-kings"); + }); + + test("strips special characters", () => { + expect(createSlug("Ben & Jerry's!")).toBe("ben-jerrys"); + }); + + test("collapses repeated spaces and hyphens", () => { + expect(createSlug("Foo Bar---Baz")).toBe("foo-bar-baz"); + }); + + test("keeps existing hyphens", () => { + expect(createSlug("co-op")).toBe("co-op"); + }); +}); + +describe("generateUniqueSlug", () => { + test("returns the base slug when it is not taken", () => { + expect(generateUniqueSlug("acme", ["other"])).toBe("acme"); + }); + + test("appends 2 when the base slug is taken", () => { + expect(generateUniqueSlug("acme", ["acme"])).toBe("acme-2"); + }); + + test("increments until it finds a free slug", () => { + expect(generateUniqueSlug("acme", ["acme", "acme-2", "acme-3"])).toBe( + "acme-4", + ); + }); +}); diff --git a/packages/api/tests/user.test.ts b/packages/api/tests/user.test.ts new file mode 100644 index 00000000..94cf2ed2 --- /dev/null +++ b/packages/api/tests/user.test.ts @@ -0,0 +1,90 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; + +import { appRouter } from "../src/root"; +import { createCallerFactory, createTRPCContext } from "../src/trpc"; +import { adminSession, chain } from "./helpers"; + +const db = vi.hoisted(() => ({ + query: { + User: { findFirst: vi.fn() }, + }, + insert: vi.fn(), + update: vi.fn(), +})); + +vi.mock("@cooper/db/client", () => ({ db })); +vi.mock("@cooper/auth", () => ({ + auth: { api: { getSession: vi.fn() } }, +})); + +async function caller() { + const ctx = await createTRPCContext({ + session: adminSession, + headers: new Headers(), + }); + return createCallerFactory(appRouter)(ctx); +} + +describe("user router", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + test("create inserts a new user when the email is not taken", async () => { + const inserted = [{ id: "u1", email: "new@x.com", role: "STUDENT" }]; + db.query.User.findFirst.mockResolvedValue(undefined); + const insertChain = chain(inserted); + db.insert.mockReturnValue(insertChain); + + const result = await ( + await caller() + ).user.create({ + email: "new@x.com", + role: "STUDENT", + }); + + expect(db.insert).toHaveBeenCalledOnce(); + expect(db.update).not.toHaveBeenCalled(); + expect(result).toEqual(inserted); + // isDisabled defaults to false when omitted. + expect(insertChain.values).toHaveBeenCalledWith( + expect.objectContaining({ + email: "new@x.com", + role: "STUDENT", + isDisabled: false, + }), + ); + }); + + test("create updates the existing user instead of inserting", async () => { + const updated = [{ id: "u1", email: "dupe@x.com", role: "ADMIN" }]; + db.query.User.findFirst.mockResolvedValue({ + id: "u1", + email: "dupe@x.com", + }); + const updateChain = chain(updated); + db.update.mockReturnValue(updateChain); + + const result = await ( + await caller() + ).user.create({ + email: "dupe@x.com", + role: "ADMIN", + isDisabled: true, + }); + + expect(db.update).toHaveBeenCalledOnce(); + expect(db.insert).not.toHaveBeenCalled(); + expect(result).toEqual(updated); + expect(updateChain.set).toHaveBeenCalledWith( + expect.objectContaining({ role: "ADMIN", isDisabled: true }), + ); + }); + + test("create rejects an invalid email", async () => { + await expect( + (await caller()).user.create({ email: "not-an-email", role: "STUDENT" }), + ).rejects.toThrow(); + expect(db.query.User.findFirst).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/db/tests/companyRequest.test.ts b/packages/db/tests/companyRequest.test.ts new file mode 100644 index 00000000..4f017346 --- /dev/null +++ b/packages/db/tests/companyRequest.test.ts @@ -0,0 +1,138 @@ +import { describe, expect, test } from "vitest"; + +import { + CompanyRequest, + CreateCompanyRequestSchema, + RequestRelations, +} from "../src/schema/companyRequest"; +import { Industry, RequestStatus } from "../src/schema/misc"; + +describe("CompanyRequest table", () => { + test("is defined with the expected columns", () => { + expect(CompanyRequest).toBeDefined(); + const columns = CompanyRequest; + expect(columns.id).toBeDefined(); + expect(columns.companyName).toBeDefined(); + expect(columns.companyDescription).toBeDefined(); + expect(columns.industry).toBeDefined(); + expect(columns.website).toBeDefined(); + expect(columns.locationId).toBeDefined(); + expect(columns.roleTitle).toBeDefined(); + expect(columns.roleDescription).toBeDefined(); + expect(columns.createdAt).toBeDefined(); + expect(columns.status).toBeDefined(); + }); + + test("status column defaults to PENDING and is not null", () => { + expect(CompanyRequest.status.default).toBe("PENDING"); + expect(CompanyRequest.status.notNull).toBe(true); + }); + + test("id column is the primary key with a random default", () => { + expect(CompanyRequest.id.primary).toBe(true); + expect(CompanyRequest.id.notNull).toBe(true); + }); +}); + +describe("RequestRelations", () => { + test("is defined", () => { + expect(RequestRelations).toBeDefined(); + }); +}); + +describe("CreateCompanyRequestSchema", () => { + const validInput = { + companyName: "Acme Corp", + companyDescription: "A company", + industry: Industry.TECHNOLOGY, + website: "https://acme.example", + locationId: "loc-123", + roleTitle: "Software Engineer", + roleDescription: "Builds things", + status: RequestStatus.PENDING, + }; + + test("parses a fully valid input", () => { + const result = CreateCompanyRequestSchema.safeParse(validInput); + expect(result.success).toBe(true); + }); + + test("omits id and createdAt fields", () => { + const result = CreateCompanyRequestSchema.safeParse({ + ...validInput, + id: "some-id", + createdAt: new Date(), + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data).not.toHaveProperty("id"); + expect(result.data).not.toHaveProperty("createdAt"); + } + }); + + test("allows optional companyDescription and website to be omitted", () => { + const { companyDescription, website, ...rest } = validInput; + void companyDescription; + void website; + const result = CreateCompanyRequestSchema.safeParse(rest); + expect(result.success).toBe(true); + }); + + test("rejects an invalid industry value", () => { + const result = CreateCompanyRequestSchema.safeParse({ + ...validInput, + industry: "NOT_A_REAL_INDUSTRY", + }); + expect(result.success).toBe(false); + }); + + test("rejects an invalid status value", () => { + const result = CreateCompanyRequestSchema.safeParse({ + ...validInput, + status: "NOT_A_STATUS", + }); + expect(result.success).toBe(false); + }); + + test("rejects a missing required roleTitle", () => { + const { roleTitle, ...rest } = validInput; + void roleTitle; + const result = CreateCompanyRequestSchema.safeParse(rest); + expect(result.success).toBe(false); + }); + + test("allows locationId to be omitted (nullable column)", () => { + const { locationId, ...rest } = validInput; + void locationId; + const result = CreateCompanyRequestSchema.safeParse(rest); + expect(result.success).toBe(true); + }); + + test("rejects a non-string companyName", () => { + const result = CreateCompanyRequestSchema.safeParse({ + ...validInput, + companyName: 123, + }); + expect(result.success).toBe(false); + }); + + test("accepts every Industry enum value", () => { + for (const industry of Object.values(Industry)) { + const result = CreateCompanyRequestSchema.safeParse({ + ...validInput, + industry, + }); + expect(result.success).toBe(true); + } + }); + + test("accepts every RequestStatus enum value", () => { + for (const status of Object.values(RequestStatus)) { + const result = CreateCompanyRequestSchema.safeParse({ + ...validInput, + status, + }); + expect(result.success).toBe(true); + } + }); +}); diff --git a/packages/db/tests/enums.test.ts b/packages/db/tests/enums.test.ts new file mode 100644 index 00000000..212734b1 --- /dev/null +++ b/packages/db/tests/enums.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, test } from "vitest"; + +import { enumToPgEnum } from "../src/utils/enums"; + +describe("enumToPgEnum", () => { + test("returns the enum values as a tuple", () => { + enum Fruit { + Apple = "APPLE", + Banana = "BANANA", + } + expect(enumToPgEnum(Fruit)).toEqual(["APPLE", "BANANA"]); + }); + + test("works with a plain record of values", () => { + expect(enumToPgEnum({ a: "ONE", b: "TWO", c: "THREE" })).toEqual([ + "ONE", + "TWO", + "THREE", + ]); + }); +}); diff --git a/packages/db/tests/profilesToCompanies.test.ts b/packages/db/tests/profilesToCompanies.test.ts new file mode 100644 index 00000000..168a4995 --- /dev/null +++ b/packages/db/tests/profilesToCompanies.test.ts @@ -0,0 +1,72 @@ +import { createTableRelationsHelpers } from "drizzle-orm"; +import { getTableConfig } from "drizzle-orm/pg-core"; +import { describe, expect, test } from "vitest"; + +import { + CreateProfileToCompanySchema, + ProfilesToCompanies, + ProfilesToCompaniesRelations, +} from "../src/schema/profilesToCompanies"; + +describe("ProfilesToCompanies table", () => { + test("defines the profileId and companyId columns", () => { + expect(ProfilesToCompanies.profileId).toBeDefined(); + expect(ProfilesToCompanies.companyId).toBeDefined(); + expect(ProfilesToCompanies.profileId.notNull).toBe(true); + expect(ProfilesToCompanies.companyId.notNull).toBe(true); + }); + + test("uses a composite primary key over both columns", () => { + const { primaryKeys } = getTableConfig(ProfilesToCompanies); + expect(primaryKeys).toHaveLength(1); + const pkColumns = primaryKeys[0]?.columns.map((c) => c.name); + expect(pkColumns).toEqual(["profileId", "companyId"]); + }); + + test("declares foreign keys to the profiles and companies tables", () => { + const { foreignKeys } = getTableConfig(ProfilesToCompanies); + expect(foreignKeys).toHaveLength(2); + const referencedTables = foreignKeys + .map((fk) => getTableConfig(fk.reference().foreignTable).name) + .sort(); + expect(referencedTables).toEqual(["company", "profile"]); + }); +}); + +describe("ProfilesToCompaniesRelations", () => { + test("maps profile and company relations to their tables", () => { + const relations = ProfilesToCompaniesRelations.config( + createTableRelationsHelpers(ProfilesToCompanies), + ); + expect(Object.keys(relations).sort()).toEqual(["company", "profile"]); + expect(getTableConfig(relations.profile.referencedTable).name).toBe( + "profile", + ); + expect(getTableConfig(relations.company.referencedTable).name).toBe( + "company", + ); + }); +}); + +describe("CreateProfileToCompanySchema", () => { + test("parses a valid profile/company pair", () => { + const result = CreateProfileToCompanySchema.safeParse({ + profileId: "p1", + companyId: "c1", + }); + expect(result.success).toBe(true); + }); + + test("rejects a missing companyId", () => { + const result = CreateProfileToCompanySchema.safeParse({ profileId: "p1" }); + expect(result.success).toBe(false); + }); + + test("rejects non-string ids", () => { + const result = CreateProfileToCompanySchema.safeParse({ + profileId: 1, + companyId: 2, + }); + expect(result.success).toBe(false); + }); +}); diff --git a/packages/db/tests/profilesToRoles.test.ts b/packages/db/tests/profilesToRoles.test.ts new file mode 100644 index 00000000..182c94e6 --- /dev/null +++ b/packages/db/tests/profilesToRoles.test.ts @@ -0,0 +1,70 @@ +import { createTableRelationsHelpers } from "drizzle-orm"; +import { getTableConfig } from "drizzle-orm/pg-core"; +import { describe, expect, test } from "vitest"; + +import { + CreateProfileToRoleSchema, + ProfilesToRoles, + ProfilesToRolesRelations, +} from "../src/schema/profilesToRoles"; + +describe("ProfilesToRoles table", () => { + test("defines the profileId and roleId columns", () => { + expect(ProfilesToRoles.profileId).toBeDefined(); + expect(ProfilesToRoles.roleId).toBeDefined(); + expect(ProfilesToRoles.profileId.notNull).toBe(true); + expect(ProfilesToRoles.roleId.notNull).toBe(true); + }); + + test("uses a composite primary key over both columns", () => { + const { primaryKeys } = getTableConfig(ProfilesToRoles); + expect(primaryKeys).toHaveLength(1); + const pkColumns = primaryKeys[0]?.columns.map((c) => c.name); + expect(pkColumns).toEqual(["profileId", "roleId"]); + }); + + test("declares foreign keys to the profiles and roles tables", () => { + const { foreignKeys } = getTableConfig(ProfilesToRoles); + expect(foreignKeys).toHaveLength(2); + const referencedTables = foreignKeys + .map((fk) => getTableConfig(fk.reference().foreignTable).name) + .sort(); + expect(referencedTables).toEqual(["profile", "role"]); + }); +}); + +describe("ProfilesToRolesRelations", () => { + test("maps profile and role relations to their tables", () => { + const relations = ProfilesToRolesRelations.config( + createTableRelationsHelpers(ProfilesToRoles), + ); + expect(Object.keys(relations).sort()).toEqual(["profile", "role"]); + expect(getTableConfig(relations.profile.referencedTable).name).toBe( + "profile", + ); + expect(getTableConfig(relations.role.referencedTable).name).toBe("role"); + }); +}); + +describe("CreateProfileToRoleSchema", () => { + test("parses a valid profile/role pair", () => { + const result = CreateProfileToRoleSchema.safeParse({ + profileId: "p1", + roleId: "r1", + }); + expect(result.success).toBe(true); + }); + + test("rejects a missing roleId", () => { + const result = CreateProfileToRoleSchema.safeParse({ profileId: "p1" }); + expect(result.success).toBe(false); + }); + + test("rejects non-string ids", () => { + const result = CreateProfileToRoleSchema.safeParse({ + profileId: 1, + roleId: 2, + }); + expect(result.success).toBe(false); + }); +}); diff --git a/packages/db/tests/profliesToReviews.test.ts b/packages/db/tests/profliesToReviews.test.ts new file mode 100644 index 00000000..7b2a370d --- /dev/null +++ b/packages/db/tests/profliesToReviews.test.ts @@ -0,0 +1,72 @@ +import { createTableRelationsHelpers } from "drizzle-orm"; +import { getTableConfig } from "drizzle-orm/pg-core"; +import { describe, expect, test } from "vitest"; + +import { + CreateProfileToReviewSchema, + ProfilesToReviews, + ProfilesToReviewsRelations, +} from "../src/schema/profliesToReviews"; + +describe("ProfilesToReviews table", () => { + test("defines the profileId and reviewId columns", () => { + expect(ProfilesToReviews.profileId).toBeDefined(); + expect(ProfilesToReviews.reviewId).toBeDefined(); + expect(ProfilesToReviews.profileId.notNull).toBe(true); + expect(ProfilesToReviews.reviewId.notNull).toBe(true); + }); + + test("uses a composite primary key over both columns", () => { + const { primaryKeys } = getTableConfig(ProfilesToReviews); + expect(primaryKeys).toHaveLength(1); + const pkColumns = primaryKeys[0]?.columns.map((c) => c.name); + expect(pkColumns).toEqual(["profileId", "reviewId"]); + }); + + test("declares foreign keys to the profiles and reviews tables", () => { + const { foreignKeys } = getTableConfig(ProfilesToReviews); + expect(foreignKeys).toHaveLength(2); + const referencedTables = foreignKeys + .map((fk) => getTableConfig(fk.reference().foreignTable).name) + .sort(); + expect(referencedTables).toEqual(["profile", "review"]); + }); +}); + +describe("ProfilesToReviewsRelations", () => { + test("maps profile and review relations to their tables", () => { + const relations = ProfilesToReviewsRelations.config( + createTableRelationsHelpers(ProfilesToReviews), + ); + expect(Object.keys(relations).sort()).toEqual(["profile", "review"]); + expect(getTableConfig(relations.profile.referencedTable).name).toBe( + "profile", + ); + expect(getTableConfig(relations.review.referencedTable).name).toBe( + "review", + ); + }); +}); + +describe("CreateProfileToReviewSchema", () => { + test("parses a valid profile/review pair", () => { + const result = CreateProfileToReviewSchema.safeParse({ + profileId: "p1", + reviewId: "rev1", + }); + expect(result.success).toBe(true); + }); + + test("rejects a missing reviewId", () => { + const result = CreateProfileToReviewSchema.safeParse({ profileId: "p1" }); + expect(result.success).toBe(false); + }); + + test("rejects non-string ids", () => { + const result = CreateProfileToReviewSchema.safeParse({ + profileId: 1, + reviewId: 2, + }); + expect(result.success).toBe(false); + }); +}); diff --git a/packages/db/tsconfig.json b/packages/db/tsconfig.json index 95a29d02..41535bd4 100644 --- a/packages/db/tsconfig.json +++ b/packages/db/tsconfig.json @@ -5,6 +5,6 @@ "outDir": "dist", "tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json" }, - "include": ["src"], + "include": ["src", "tests"], "exclude": ["node_modules"] } diff --git a/packages/ui/tests/autocomplete.test.tsx b/packages/ui/tests/autocomplete.test.tsx new file mode 100644 index 00000000..8051d14c --- /dev/null +++ b/packages/ui/tests/autocomplete.test.tsx @@ -0,0 +1,171 @@ +import { fireEvent, render, screen, within } from "@testing-library/react"; +import { describe, expect, test, vi } from "vitest"; + +import Autocomplete from "../src/autocomplete"; + +const options = [ + { value: "react", label: "React" }, + { value: "vue", label: "Vue" }, + { value: "svelte", label: "Svelte" }, +]; + +function getInput() { + return screen.getByRole("textbox"); +} + +describe("Autocomplete", () => { + test("renders the placeholder and a closed dropdown", () => { + render( + , + ); + expect(getInput()).toHaveAttribute("placeholder", "Pick"); + expect(screen.queryByText("React")).not.toBeInTheDocument(); + }); + + test("opens the dropdown on focus and lists every option", () => { + render(); + fireEvent.focus(getInput()); + expect(screen.getByText("React")).toBeInTheDocument(); + expect(screen.getByText("Vue")).toBeInTheDocument(); + expect(screen.getByText("Svelte")).toBeInTheDocument(); + }); + + test("filters options by the search text and reports changes", () => { + const onSearchChange = vi.fn(); + render( + , + ); + fireEvent.change(getInput(), { target: { value: "vu" } }); + expect(onSearchChange).toHaveBeenCalledWith("vu"); + expect(screen.getByText("Vue")).toBeInTheDocument(); + expect(screen.queryByText("React")).not.toBeInTheDocument(); + }); + + test("shows an empty state when nothing matches", () => { + render(); + fireEvent.change(getInput(), { target: { value: "zzz" } }); + expect(screen.getByText("No results found.")).toBeInTheDocument(); + }); + + test("toggles a selection on in multi-select mode", () => { + const onChange = vi.fn(); + render(); + fireEvent.focus(getInput()); + fireEvent.click(screen.getByText("React")); + expect(onChange).toHaveBeenCalledWith(["react"]); + }); + + test("toggles an already-selected option off", () => { + const onChange = vi.fn(); + render( + , + ); + fireEvent.focus(getInput()); + fireEvent.click(screen.getByText("React")); + expect(onChange).toHaveBeenCalledWith([]); + }); + + test("renders chips for selected values when closed and removes one", () => { + const onChange = vi.fn(); + render( + , + ); + // closed by default → chips visible + const chips = screen.getByText("React").closest("span"); + expect(chips).toBeInTheDocument(); + fireEvent.click(within(chips as HTMLElement).getByRole("button")); + expect(onChange).toHaveBeenCalledWith(["vue"]); + }); + + test("clears the whole selection with the clear button", () => { + const onChange = vi.fn(); + render( + , + ); + // The clear (X) button is the first button rendered next to the input. + const clearButton = screen.getAllByRole("button")[0]!; + fireEvent.click(clearButton); + expect(onChange).toHaveBeenCalledWith([]); + }); + + test("selects an exact match when Enter is pressed", () => { + const onChange = vi.fn(); + render(); + const input = getInput(); + fireEvent.change(input, { target: { value: "vue" } }); + fireEvent.keyDown(input, { key: "Enter" }); + expect(onChange).toHaveBeenCalledWith(["vue"]); + }); + + test("selects the first filtered result on Enter without an exact match", () => { + const onChange = vi.fn(); + render(); + const input = getInput(); + fireEvent.change(input, { target: { value: "s" } }); + fireEvent.keyDown(input, { key: "Enter" }); + // "s" matches both "Svelte"; first filtered option is selected + expect(onChange).toHaveBeenCalledWith(["svelte"]); + }); + + test("single-select shows the chosen label and closes after picking", () => { + const onChange = vi.fn(); + render( + , + ); + fireEvent.focus(getInput()); + fireEvent.click(screen.getByText("Vue")); + expect(onChange).toHaveBeenCalledWith(["vue"]); + // dropdown closed: no second "Vue" option remains, label shown in input + expect(screen.queryByText("React")).not.toBeInTheDocument(); + }); + + test("single-select displays the current label in the input", () => { + render( + , + ); + expect(getInput()).toHaveValue("Svelte"); + }); + + test("renders an inline dropdown for isInMenuContent instead of a portal", () => { + const { container } = render( + , + ); + fireEvent.focus(getInput()); + // inline variant lives inside the component container, not a body portal + expect(within(container).getByText("React")).toBeInTheDocument(); + expect( + document.querySelector("[data-autocomplete-portal]"), + ).not.toBeInTheDocument(); + }); + + test("closes the dropdown on an outside pointer down", () => { + render(); + fireEvent.focus(getInput()); + expect(screen.getByText("React")).toBeInTheDocument(); + fireEvent.pointerDown(document.body); + expect(screen.queryByText("React")).not.toBeInTheDocument(); + }); +}); diff --git a/packages/ui/tests/custom-toaster.test.tsx b/packages/ui/tests/custom-toaster.test.tsx new file mode 100644 index 00000000..0d4fecac --- /dev/null +++ b/packages/ui/tests/custom-toaster.test.tsx @@ -0,0 +1,56 @@ +import { render, screen } from "@testing-library/react"; +import { beforeEach, describe, expect, test, vi } from "vitest"; + +const h = vi.hoisted(() => ({ toasts: [] as Record[] })); + +vi.mock("../src/hooks/use-toast", () => ({ + useToast: () => ({ toasts: h.toasts }), +})); +vi.mock("../src/success-toast", () => ({ + SuccessToast: ({ description }: { description?: string }) => ( +
{description}
+ ), +})); +vi.mock("../src/error-toast", () => ({ + ErrorToast: ({ description }: { description?: string }) => ( +
{description}
+ ), +})); + +import { CustomToaster } from "../src/custom-toaster"; + +describe("CustomToaster", () => { + beforeEach(() => { + h.toasts = []; + }); + + test("renders nothing notable when there are no toasts", () => { + render(); + expect(screen.queryByTestId("success-toast")).not.toBeInTheDocument(); + expect(screen.queryByTestId("error-toast")).not.toBeInTheDocument(); + }); + + test("renders a SuccessToast for the toast-success class", () => { + h.toasts = [{ id: "1", description: "Saved", className: "toast-success" }]; + render(); + expect(screen.getByTestId("success-toast")).toHaveTextContent("Saved"); + }); + + test("renders an ErrorToast for the toast-error class", () => { + h.toasts = [{ id: "1", description: "Bad", className: "toast-error" }]; + render(); + expect(screen.getByTestId("error-toast")).toHaveTextContent("Bad"); + }); + + test("renders an ErrorToast for the destructive variant", () => { + h.toasts = [{ id: "1", description: "Boom", variant: "destructive" }]; + render(); + expect(screen.getByTestId("error-toast")).toHaveTextContent("Boom"); + }); + + test("falls back to a SuccessToast for an unknown variant", () => { + h.toasts = [{ id: "1", description: "Plain", variant: "default" }]; + render(); + expect(screen.getByTestId("success-toast")).toHaveTextContent("Plain"); + }); +}); diff --git a/packages/ui/tests/dropdown-menu.test.tsx b/packages/ui/tests/dropdown-menu.test.tsx new file mode 100644 index 00000000..fabc8f3e --- /dev/null +++ b/packages/ui/tests/dropdown-menu.test.tsx @@ -0,0 +1,106 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, test } from "vitest"; + +import { + DropdownMenu, + DropdownMenuCheckboxItem, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuTrigger, +} from "../src/dropdown-menu"; + +// Radix relies on pointer-capture/scroll APIs that jsdom does not implement. +const proto = Element.prototype as Partial; +if (!proto.hasPointerCapture) { + Element.prototype.hasPointerCapture = () => false; +} +if (!proto.scrollIntoView) { + Element.prototype.scrollIntoView = () => undefined; +} + +// `forceMount` keeps the portalled content (and its items) in the tree so the +// wrapper render bodies execute without needing to drive the open animation. +function renderMenu() { + return render( + + Open + + Plain label + Inset label + Plain item + Inset item + + Checkbox item + + + + Radio item + + + + + ⌘K + + + + Sub trigger + + Inset sub trigger + + + Sub item + + + + + , + ); +} + +describe("DropdownMenu wrappers", () => { + test("render every menu item variant", () => { + renderMenu(); + expect(screen.getByText("Open")).toBeInTheDocument(); + expect(screen.getByText("Plain label")).toBeInTheDocument(); + expect(screen.getByText("Inset label")).toBeInTheDocument(); + expect(screen.getByText("Plain item")).toBeInTheDocument(); + expect(screen.getByText("Inset item")).toBeInTheDocument(); + expect(screen.getByText("Checkbox item")).toBeInTheDocument(); + expect(screen.getByText("Radio item")).toBeInTheDocument(); + expect(screen.getByText("⌘K")).toBeInTheDocument(); + expect(screen.getByText("Sub trigger")).toBeInTheDocument(); + expect(screen.getByText("Inset sub trigger")).toBeInTheDocument(); + expect(screen.getByText("Sub item")).toBeInTheDocument(); + }); + + test("forwards custom class names onto the rendered nodes", () => { + renderMenu(); + expect(document.querySelector(".custom-content")).not.toBeNull(); + expect(document.querySelector(".custom-checkbox")).not.toBeNull(); + expect(document.querySelector(".custom-radio")).not.toBeNull(); + expect(document.querySelector(".custom-separator")).not.toBeNull(); + expect(document.querySelector(".custom-subcontent")).not.toBeNull(); + }); + + test("the shortcut applies its default opacity classes", () => { + renderMenu(); + const shortcut = screen.getByText("⌘K"); + expect(shortcut).toHaveClass("custom-shortcut"); + expect(shortcut).toHaveClass("ml-auto"); + }); + + test("inset variants add left padding", () => { + renderMenu(); + expect(screen.getByText("Inset item")).toHaveClass("pl-8"); + expect(screen.getByText("Inset label")).toHaveClass("pl-8"); + }); +}); diff --git a/packages/ui/tests/logo.test.tsx b/packages/ui/tests/logo.test.tsx new file mode 100644 index 00000000..5211c054 --- /dev/null +++ b/packages/ui/tests/logo.test.tsx @@ -0,0 +1,46 @@ +import type { ComponentProps } from "react"; +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, test, vi } from "vitest"; + +vi.mock("next/image", () => ({ + default: ({ + src, + alt, + onError, + }: { + src: string; + alt: string; + onError?: () => void; + }) => {alt}, +})); + +import Logo from "../src/logo"; + +const company = { + id: "c1", + name: "Acme Corp", + website: "https://acme.com", +} as unknown as ComponentProps["company"]; + +describe("Logo", () => { + test("builds the logo URL from the company website without protocol", () => { + render(); + const img = screen.getByAltText("Logo of Acme Corp"); + expect(img.getAttribute("src")).toContain("acme.com"); + expect(img.getAttribute("src")).not.toContain("https://acme.com"); + }); + + test("falls back to a name-based domain when no website is set", () => { + render(); + const img = screen.getByAltText("Logo of Acme Corp"); + // Spaces stripped: "AcmeCorp.com" + expect(img.getAttribute("src")).toContain("AcmeCorp.com"); + }); + + test("renders an initial-letter fallback when the image errors", () => { + render(); + fireEvent.error(screen.getByAltText("Logo of Acme Corp")); + expect(screen.queryByAltText("Logo of Acme Corp")).not.toBeInTheDocument(); + expect(screen.getByText("A")).toBeInTheDocument(); + }); +}); diff --git a/packages/ui/tests/pagination.test.tsx b/packages/ui/tests/pagination.test.tsx new file mode 100644 index 00000000..8d54f327 --- /dev/null +++ b/packages/ui/tests/pagination.test.tsx @@ -0,0 +1,50 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, test, vi } from "vitest"; + +import { Pagination } from "../src/pagination"; + +describe("Pagination", () => { + test("renders nothing when there is a single page", () => { + const { container } = render( + , + ); + expect(container).toBeEmptyDOMElement(); + }); + + test("shows the current page and total", () => { + render( + , + ); + expect(screen.getByText("Page 2 of 5")).toBeInTheDocument(); + }); + + test("disables previous on the first page", () => { + const onPageChange = vi.fn(); + render( + , + ); + const prev = screen.getByLabelText("Previous page"); + expect(prev).toBeDisabled(); + fireEvent.click(prev); + expect(onPageChange).not.toHaveBeenCalled(); + }); + + test("disables next on the last page", () => { + const onPageChange = vi.fn(); + render( + , + ); + expect(screen.getByLabelText("Next page")).toBeDisabled(); + }); + + test("navigates forward and backward", () => { + const onPageChange = vi.fn(); + render( + , + ); + fireEvent.click(screen.getByLabelText("Next page")); + expect(onPageChange).toHaveBeenCalledWith(3); + fireEvent.click(screen.getByLabelText("Previous page")); + expect(onPageChange).toHaveBeenCalledWith(1); + }); +}); diff --git a/packages/ui/tests/setup.ts b/packages/ui/tests/setup.ts new file mode 100644 index 00000000..ed1f5c7e --- /dev/null +++ b/packages/ui/tests/setup.ts @@ -0,0 +1,9 @@ +import "@testing-library/jest-dom/vitest"; + +import { cleanup } from "@testing-library/react"; +import { afterEach } from "vitest"; + +// Unmount React trees between tests so queries don't leak across cases. +afterEach(() => { + cleanup(); +}); diff --git a/packages/ui/tests/success-toast.test.tsx b/packages/ui/tests/success-toast.test.tsx new file mode 100644 index 00000000..fdb451f9 --- /dev/null +++ b/packages/ui/tests/success-toast.test.tsx @@ -0,0 +1,62 @@ +import type { ReactNode } from "react"; +import { render, screen } from "@testing-library/react"; +import { describe, expect, test, vi } from "vitest"; + +vi.mock("next/image", () => ({ + default: ({ src, alt }: { src: string; alt: string }) => ( + {alt} + ), +})); + +import { ToastProvider, ToastViewport } from "../src/toast"; +import { SuccessToast } from "../src/success-toast"; + +function renderSuccessToast(props = {}) { + return render( + + + + , + ); +} + +describe("SuccessToast", () => { + test("renders the check icon", () => { + renderSuccessToast(); + const icon = screen.getByAltText("Check icon"); + expect(icon).toBeInTheDocument(); + expect(icon).toHaveAttribute("src", "/svg/toastCheck.svg"); + }); + + test("renders the description when provided", () => { + renderSuccessToast({ description: "Saved successfully." }); + expect(screen.getByText("Saved successfully.")).toBeInTheDocument(); + }); + + test("omits the description node when none is provided", () => { + renderSuccessToast(); + // Only the icon's alt text is present; no description text renders. + expect(screen.queryByText("Saved successfully.")).not.toBeInTheDocument(); + }); + + test("renders an action when provided", () => { + const action: ReactNode = ; + renderSuccessToast({ action }); + expect(screen.getByRole("button", { name: "Undo" })).toBeInTheDocument(); + }); + + test("merges a custom className onto the toast root", () => { + renderSuccessToast({ + className: "my-custom-class", + description: "Styled", + }); + expect(screen.getByText("Styled").closest("li")).toHaveClass( + "my-custom-class", + ); + }); + + test("applies the success styling on the toast root", () => { + renderSuccessToast({ description: "Done" }); + expect(screen.getByText("Done").closest("li")).toHaveClass("bg-green-50"); + }); +}); diff --git a/packages/ui/tests/toast.test.tsx b/packages/ui/tests/toast.test.tsx new file mode 100644 index 00000000..50fcf589 --- /dev/null +++ b/packages/ui/tests/toast.test.tsx @@ -0,0 +1,85 @@ +import type { ReactNode } from "react"; +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, test, vi } from "vitest"; + +import { + Toast, + ToastAction, + ToastClose, + ToastDescription, + ToastProvider, + ToastTitle, + ToastViewport, +} from "../src/toast"; + +function renderToast(children: ReactNode, toastProps = {}) { + return render( + + + {children} + + + , + ); +} + +describe("Toast", () => { + test("renders the title and description", () => { + renderToast( + <> + Saved + Your changes were saved. + , + ); + expect(screen.getByText("Saved")).toBeInTheDocument(); + expect(screen.getByText("Your changes were saved.")).toBeInTheDocument(); + }); + + test("applies the default variant classes", () => { + renderToast(Default); + expect(screen.getByText("Default").closest("li")).toHaveClass( + "bg-background", + ); + }); + + test("applies the destructive variant classes", () => { + renderToast(Oops, { variant: "destructive" }); + expect(screen.getByText("Oops").closest("li")).toHaveClass("destructive"); + }); + + test("merges a custom className onto the toast", () => { + renderToast(Styled, { + className: "my-custom-class", + }); + expect(screen.getByText("Styled").closest("li")).toHaveClass( + "my-custom-class", + ); + }); + + test("fires the action's click handler", () => { + const onClick = vi.fn(); + renderToast( + <> + With action + + Undo + + , + ); + fireEvent.click(screen.getByText("Undo")); + expect(onClick).toHaveBeenCalledOnce(); + }); + + test("closes the toast when the close button is clicked", () => { + const onOpenChange = vi.fn(); + renderToast( + <> + Closable + + , + { onOpenChange }, + ); + fireEvent.click(screen.getByLabelText("Close")); + expect(onOpenChange).toHaveBeenCalledWith(false); + }); +}); diff --git a/packages/ui/tests/use-custom-toast.test.ts b/packages/ui/tests/use-custom-toast.test.ts new file mode 100644 index 00000000..afcb2516 --- /dev/null +++ b/packages/ui/tests/use-custom-toast.test.ts @@ -0,0 +1,50 @@ +import { act, renderHook } from "@testing-library/react"; +import { describe, expect, test } from "vitest"; + +import { useCustomToast } from "../src/hooks/use-custom-toast"; + +describe("useCustomToast", () => { + test("success adds a default-variant toast tagged toast-success", () => { + const { result } = renderHook(() => useCustomToast()); + act(() => { + result.current.toast.success("Saved!"); + }); + const t = result.current.toasts[0]; + expect(t?.description).toBe("Saved!"); + expect(t?.className).toBe("toast-success"); + expect(t?.variant).toBe("default"); + }); + + test("error adds a destructive toast tagged toast-error", () => { + const { result } = renderHook(() => useCustomToast()); + act(() => { + result.current.toast.error("Oops"); + }); + const t = result.current.toasts[0]; + expect(t?.description).toBe("Oops"); + expect(t?.className).toBe("toast-error"); + expect(t?.variant).toBe("destructive"); + }); + + test("warning and info use their own class names", () => { + const { result } = renderHook(() => useCustomToast()); + act(() => { + result.current.toast.warning("Careful"); + }); + expect(result.current.toasts[0]?.className).toBe("toast-warning"); + + act(() => { + result.current.toast.info("FYI"); + }); + expect(result.current.toasts[0]?.className).toBe("toast-info"); + }); + + test("custom forwards directly to the base toast", () => { + const { result } = renderHook(() => useCustomToast()); + act(() => { + result.current.toast.custom({ description: "Raw", className: "x" }); + }); + expect(result.current.toasts[0]?.description).toBe("Raw"); + expect(result.current.toasts[0]?.className).toBe("x"); + }); +}); diff --git a/packages/ui/tests/use-toast.test.ts b/packages/ui/tests/use-toast.test.ts new file mode 100644 index 00000000..b62a2a03 --- /dev/null +++ b/packages/ui/tests/use-toast.test.ts @@ -0,0 +1,100 @@ +import { act, renderHook } from "@testing-library/react"; +import { describe, expect, test } from "vitest"; + +import { reducer, toast, useToast } from "../src/hooks/use-toast"; + +type ToastShape = Parameters[0]["toasts"][number]; + +const makeToast = (overrides: Partial = {}): ToastShape => ({ + id: "1", + open: true, + ...overrides, +}); + +describe("use-toast reducer", () => { + test("ADD_TOAST prepends and enforces the toast limit of 1", () => { + const first = makeToast({ id: "1" }); + const second = makeToast({ id: "2" }); + let state = reducer( + { toasts: [first] }, + { type: "ADD_TOAST", toast: second }, + ); + expect(state.toasts).toHaveLength(1); + expect(state.toasts[0]?.id).toBe("2"); + state = reducer({ toasts: [] }, { type: "ADD_TOAST", toast: first }); + expect(state.toasts[0]?.id).toBe("1"); + }); + + test("UPDATE_TOAST merges fields for the matching id", () => { + const state = reducer( + { toasts: [makeToast({ id: "1", title: "old" })] }, + { type: "UPDATE_TOAST", toast: { id: "1", title: "new" } }, + ); + expect(state.toasts[0]?.title).toBe("new"); + }); + + test("DISMISS_TOAST closes the matching toast", () => { + const state = reducer( + { toasts: [makeToast({ id: "1", open: true })] }, + { type: "DISMISS_TOAST", toastId: "1" }, + ); + expect(state.toasts[0]?.open).toBe(false); + }); + + test("DISMISS_TOAST with no id closes all toasts", () => { + const state = reducer( + { toasts: [makeToast({ id: "1", open: true })] }, + { type: "DISMISS_TOAST" }, + ); + expect(state.toasts.every((t) => t.open === false)).toBe(true); + }); + + test("REMOVE_TOAST removes a specific toast", () => { + const state = reducer( + { toasts: [makeToast({ id: "1" }), makeToast({ id: "2" })] }, + { type: "REMOVE_TOAST", toastId: "1" }, + ); + expect(state.toasts.map((t) => t.id)).toEqual(["2"]); + }); + + test("REMOVE_TOAST with no id clears all toasts", () => { + const state = reducer( + { toasts: [makeToast({ id: "1" }), makeToast({ id: "2" })] }, + { type: "REMOVE_TOAST", toastId: undefined }, + ); + expect(state.toasts).toEqual([]); + }); +}); + +describe("toast() + useToast()", () => { + test("adding a toast surfaces it through useToast", () => { + const { result } = renderHook(() => useToast()); + act(() => { + toast({ description: "Hello there" }); + }); + expect(result.current.toasts[0]?.description).toBe("Hello there"); + expect(result.current.toasts[0]?.open).toBe(true); + }); + + test("dismiss closes the active toast", () => { + const { result } = renderHook(() => useToast()); + let handle: ReturnType; + act(() => { + handle = toast({ description: "Closing soon" }); + }); + act(() => { + handle.dismiss(); + }); + expect(result.current.toasts[0]?.open).toBe(false); + }); + + test("toast() returns an id and update handle", () => { + let handle: ReturnType | undefined; + act(() => { + handle = toast({ description: "x" }); + }); + expect(handle?.id).toBeTypeOf("string"); + expect(handle?.update).toBeTypeOf("function"); + expect(handle?.dismiss).toBeTypeOf("function"); + }); +}); diff --git a/packages/ui/tsconfig.json b/packages/ui/tsconfig.json index 9a137e4a..f560e983 100644 --- a/packages/ui/tsconfig.json +++ b/packages/ui/tsconfig.json @@ -5,6 +5,6 @@ "jsx": "preserve", "tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json" }, - "include": ["*.ts", "src"], + "include": ["*.ts", "src", "tests"], "exclude": ["node_modules"] } diff --git a/packages/ui/vitest.config.ts b/packages/ui/vitest.config.ts new file mode 100644 index 00000000..b1e66fd3 --- /dev/null +++ b/packages/ui/vitest.config.ts @@ -0,0 +1,12 @@ +import react from "@vitejs/plugin-react"; +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + // plugin-react transforms JSX/TSX so component tests can render. + plugins: [react()], + test: { + environment: "jsdom", + setupFiles: ["./tests/setup.ts"], + include: ["tests/**/*.{test,spec}.{ts,tsx}"], + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 09845e74..7a51cd5a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -52,6 +52,12 @@ importers: '@types/estree': specifier: ^1.0.9 version: 1.0.9 + '@vitejs/plugin-react': + specifier: ^6.0.2 + version: 6.0.2(vite@8.0.10(@types/node@25.6.2)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.8.3)) + '@vitest/coverage-v8': + specifier: 4.1.5 + version: 4.1.5(vitest@4.1.5) '@vitest/ui': specifier: ^4.1.5 version: 4.1.5(vitest@4.1.5) @@ -66,7 +72,7 @@ importers: version: 5.5.4 vitest: specifier: 'catalog:' - version: 4.1.5(@types/node@25.6.2)(@vitest/ui@4.1.5)(vite@8.0.10(@types/node@25.6.2)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.8.3)) + version: 4.1.5(@types/node@25.6.2)(@vitest/coverage-v8@4.1.5)(@vitest/ui@4.1.5)(vite@8.0.10(@types/node@25.6.2)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.8.3)) apps/auth-proxy: dependencies: @@ -1315,6 +1321,10 @@ packages: resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} engines: {node: '>=6.9.0'} + '@bcoe/v8-coverage@1.0.2': + resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} + engines: {node: '>=18'} + '@better-auth/core@1.6.10': resolution: {integrity: sha512-13h/rfSGMLl7zwyOb1BSlxAQZs2nQqn/xFI/bxB7zQuS95hVgTmNbKqhHtJYkNDtuJCcjEf1sNtLBHkvaPT/vw==} peerDependencies: @@ -3814,6 +3824,9 @@ packages: '@rolldown/pluginutils@1.0.0-rc.17': resolution: {integrity: sha512-n8iosDOt6Ig1UhJ2AYqoIhHWh/isz0xpicHTzpKBeotdVsTEcxsSA/i3EVM7gQAj0rU27OLAxCjzlj15IWY7bg==} + '@rolldown/pluginutils@1.0.1': + resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + '@rollup/plugin-alias@6.0.0': resolution: {integrity: sha512-tPCzJOtS7uuVZd+xPhoy5W4vThe6KWXNmsFCNktaAh5RTqcLiSfT4huPQIXkgJ6YCOjJHvecOAzQxLFhPxKr+g==} engines: {node: '>=20.19.0'} @@ -4487,6 +4500,28 @@ packages: engines: {node: '>=18.14'} deprecated: '@vercel/postgres is deprecated. If you are setting up a new database, you can choose an alternate storage solution from the Vercel Marketplace. If you had an existing Vercel Postgres database, it should have been migrated to Neon as a native Vercel integration. You can find more details and the guide to migrate to Neon''s SDKs here: https://neon.com/docs/guides/vercel-postgres-transition-guide' + '@vitejs/plugin-react@6.0.2': + resolution: {integrity: sha512-DlSMqo4WhThw4vB8Mpn0Woe9J+Jfq1geJ61AKW0QEgLzGMNwtIMdxbDUzLxcun8W7NbJO0e2Jg/Nxm3cCSVzzg==} + engines: {node: ^20.19.0 || >=22.12.0} + peerDependencies: + '@rolldown/plugin-babel': ^0.1.7 || ^0.2.0 + babel-plugin-react-compiler: ^1.0.0 + vite: ^8.0.0 + peerDependenciesMeta: + '@rolldown/plugin-babel': + optional: true + babel-plugin-react-compiler: + optional: true + + '@vitest/coverage-v8@4.1.5': + resolution: {integrity: sha512-38C0/Ddb7HcRG0Z4/DUem8x57d2p9jYgp18mkaYswEOQBGsI1CG4f/hjm0ZCeaJfWhSZ4k7jgs29V1Zom7Ki9A==} + peerDependencies: + '@vitest/browser': 4.1.5 + vitest: 4.1.5 + peerDependenciesMeta: + '@vitest/browser': + optional: true + '@vitest/expect@4.1.5': resolution: {integrity: sha512-PWBaRY5JoKuRnHlUHfpV/KohFylaDZTupcXN1H9vYryNLOnitSw60Mw9IAE2r67NbwwzBw/Cc/8q9BK3kIX8Kw==} @@ -4768,6 +4803,9 @@ packages: resolution: {integrity: sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w==} engines: {node: '>=4'} + ast-v8-to-istanbul@1.0.2: + resolution: {integrity: sha512-dKmJxJsGItLmc5CYZKuEjuG6GnBs6PG4gohMhyFOWKaNQoYCuRZJDECaBlHmcG0lv2wc2E0uU8lESmBEumC3DQ==} + astring@1.9.0: resolution: {integrity: sha512-LElXdjswlqjWrPpJFg1Fx4wpkOCxj1TDHlSV4PlaRxHGWko024xICaa97ZkMfs6DRKlCguiAI+rbXv5GWwXIkg==} hasBin: true @@ -7042,6 +7080,18 @@ packages: resolution: {integrity: sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==} engines: {node: '>=0.10.0'} + istanbul-lib-coverage@3.2.2: + resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} + engines: {node: '>=8'} + + istanbul-lib-report@3.0.1: + resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==} + engines: {node: '>=10'} + + istanbul-reports@3.2.0: + resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} + engines: {node: '>=8'} + iterator.prototype@1.1.5: resolution: {integrity: sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==} engines: {node: '>= 0.4'} @@ -7079,6 +7129,9 @@ packages: jose@6.2.3: resolution: {integrity: sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==} + js-tokens@10.0.0: + resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -7342,6 +7395,10 @@ packages: magicast@0.5.2: resolution: {integrity: sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ==} + make-dir@4.0.0: + resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} + engines: {node: '>=10'} + markdown-extensions@2.0.0: resolution: {integrity: sha512-o5vL7aDWatOTX8LzaS1WMoaoxIiLRQJuIKKe2wAw6IeULDHaqbiqiggmx+pKvZDb1Sj+pE46Sn1T7lCqfFtg1Q==} engines: {node: '>=16'} @@ -11241,6 +11298,8 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 + '@bcoe/v8-coverage@1.0.2': {} + '@better-auth/core@1.6.10(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@3.24.4))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0)': dependencies: '@better-auth/utils': 0.4.0 @@ -14122,6 +14181,8 @@ snapshots: '@rolldown/pluginutils@1.0.0-rc.17': {} + '@rolldown/pluginutils@1.0.1': {} + '@rollup/plugin-alias@6.0.0(rollup@4.60.3)': optionalDependencies: rollup: 4.60.3 @@ -14813,6 +14874,25 @@ snapshots: - utf-8-validate optional: true + '@vitejs/plugin-react@6.0.2(vite@8.0.10(@types/node@25.6.2)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.8.3))': + dependencies: + '@rolldown/pluginutils': 1.0.1 + vite: 8.0.10(@types/node@25.6.2)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.8.3) + + '@vitest/coverage-v8@4.1.5(vitest@4.1.5)': + dependencies: + '@bcoe/v8-coverage': 1.0.2 + '@vitest/utils': 4.1.5 + ast-v8-to-istanbul: 1.0.2 + istanbul-lib-coverage: 3.2.2 + istanbul-lib-report: 3.0.1 + istanbul-reports: 3.2.0 + magicast: 0.5.2 + obug: 2.1.1 + std-env: 4.1.0 + tinyrainbow: 3.1.0 + vitest: 4.1.5(@types/node@25.6.2)(@vitest/coverage-v8@4.1.5)(@vitest/ui@4.1.5)(vite@8.0.10(@types/node@25.6.2)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.8.3)) + '@vitest/expect@4.1.5': dependencies: '@standard-schema/spec': 1.1.0 @@ -14857,7 +14937,7 @@ snapshots: sirv: 3.0.2 tinyglobby: 0.2.16 tinyrainbow: 3.1.0 - vitest: 4.1.5(@types/node@25.6.2)(@vitest/ui@4.1.5)(vite@8.0.10(@types/node@25.6.2)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.8.3)) + vitest: 4.1.5(@types/node@25.6.2)(@vitest/coverage-v8@4.1.5)(@vitest/ui@4.1.5)(vite@8.0.10(@types/node@25.6.2)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.8.3)) '@vitest/utils@4.1.5': dependencies: @@ -15180,6 +15260,12 @@ snapshots: dependencies: tslib: 2.8.1 + ast-v8-to-istanbul@1.0.2: + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + estree-walker: 3.0.3 + js-tokens: 10.0.0 + astring@1.9.0: {} async-function@1.0.0: {} @@ -15327,7 +15413,7 @@ snapshots: next: 16.2.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1) react: 18.3.1 react-dom: 18.3.1(react@18.3.1) - vitest: 4.1.5(@types/node@25.6.2)(@vitest/ui@4.1.5)(vite@8.0.10(@types/node@25.6.2)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.8.3)) + vitest: 4.1.5(@types/node@25.6.2)(@vitest/coverage-v8@4.1.5)(@vitest/ui@4.1.5)(vite@8.0.10(@types/node@25.6.2)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.8.3)) transitivePeerDependencies: - '@cloudflare/workers-types' - '@opentelemetry/api' @@ -17646,6 +17732,19 @@ snapshots: isobject@3.0.1: {} + istanbul-lib-coverage@3.2.2: {} + + istanbul-lib-report@3.0.1: + dependencies: + istanbul-lib-coverage: 3.2.2 + make-dir: 4.0.0 + supports-color: 7.2.0 + + istanbul-reports@3.2.0: + dependencies: + html-escaper: 2.0.2 + istanbul-lib-report: 3.0.1 + iterator.prototype@1.1.5: dependencies: define-data-property: 1.1.4 @@ -17697,6 +17796,8 @@ snapshots: jose@6.2.3: {} + js-tokens@10.0.0: {} + js-tokens@4.0.0: {} js-tokens@9.0.1: {} @@ -17929,6 +18030,10 @@ snapshots: '@babel/types': 7.29.0 source-map-js: 1.2.1 + make-dir@4.0.0: + dependencies: + semver: 7.8.0 + markdown-extensions@2.0.0: {} markdown-table@3.0.4: {} @@ -21248,7 +21353,7 @@ snapshots: tsx: 4.21.0 yaml: 2.8.3 - vitest@4.1.5(@types/node@25.6.2)(@vitest/ui@4.1.5)(vite@8.0.10(@types/node@25.6.2)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.8.3)): + vitest@4.1.5(@types/node@25.6.2)(@vitest/coverage-v8@4.1.5)(@vitest/ui@4.1.5)(vite@8.0.10(@types/node@25.6.2)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.8.3)): dependencies: '@vitest/expect': 4.1.5 '@vitest/mocker': 4.1.5(vite@8.0.10(@types/node@25.6.2)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.8.3)) @@ -21272,6 +21377,7 @@ snapshots: why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 25.6.2 + '@vitest/coverage-v8': 4.1.5(vitest@4.1.5) '@vitest/ui': 4.1.5(vitest@4.1.5) transitivePeerDependencies: - msw diff --git a/tooling/eslint/base.js b/tooling/eslint/base.js index 464411ba..7782ec51 100644 --- a/tooling/eslint/base.js +++ b/tooling/eslint/base.js @@ -35,7 +35,7 @@ export const restrictEnvAccess = tseslint.config({ export default tseslint.config( { // Globally ignored files - ignores: ["**/*.config.*"], + ignores: ["**/*.config.*", "**/coverage/**"], }, { files: ["**/*.js", "**/*.ts", "**/*.tsx"], @@ -73,6 +73,28 @@ export default tseslint.config( "import/consistent-type-specifier-style": ["error", "prefer-top-level"], }, }, + { + // Test files rely on mocks (which surface as `any`) and direct DOM + // assertions, so relax the strict type-aware rules that don't add value + // for test code. + files: [ + "**/*.test.ts", + "**/*.test.tsx", + "**/tests/**/*.ts", + "**/tests/**/*.tsx", + "**/test/**/*.ts", + "**/test/**/*.tsx", + ], + rules: { + "@typescript-eslint/no-non-null-assertion": "off", + "@typescript-eslint/no-unsafe-assignment": "off", + "@typescript-eslint/no-unsafe-call": "off", + "@typescript-eslint/no-unsafe-member-access": "off", + "@typescript-eslint/no-unsafe-return": "off", + "@typescript-eslint/no-unnecessary-condition": "off", + "@typescript-eslint/require-await": "off", + }, + }, { linterOptions: { reportUnusedDisableDirectives: true }, languageOptions: { parserOptions: { projectService: true } }, diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 00000000..16216c44 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,46 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + // Run tests across every workspace package (migrated from + // the deprecated vitest.workspace.ts into Vitest 4's `projects`). + projects: ["packages/*", "apps/*", "tooling/*"], + coverage: { + provider: "v8", + // `all` instruments every source file (not just imported ones) + // so the percentages reflect overall coverage, not just tested files. + all: true, + reportsDirectory: "./coverage", + reporter: ["text", "text-summary", "html", "json-summary"], + include: ["packages/*/src/**", "apps/*/src/**", "tooling/*/src/**"], + exclude: [ + "**/node_modules/**", + "**/dist/**", + "**/.next/**", + "**/*.config.*", + "**/*.d.ts", + "**/*.css", + "**/env.ts", + "**/middleware.ts", + "**/__mocks__/**", + "**/tests/**", + "**/test/**", + "packages/validators/src/index.ts", + "packages/scraper/src/levels_company_data.ts", + "packages/scraper/src/levels_company_names.ts", + "packages/auth/src/auth.ts", + "packages/auth/src/client.ts", + "packages/auth/src/index.rsc.ts", + "packages/auth/src/index.ts", + "packages/db/src/client.ts", + "packages/api/src/index.ts", + "packages/api/src/root.ts", + "packages/api/src/trpc.ts", + "apps/web/src/trpc/**", + "apps/web/src/app/styles/font.ts", + "**/[(]dashboard[)]/layout.tsx", + "**/redirection/page.tsx", + ], + }, + }, +}); diff --git a/vitest.workspace.ts b/vitest.workspace.ts deleted file mode 100644 index 3c730f83..00000000 --- a/vitest.workspace.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { defineWorkspace } from "vitest/config"; - -export default defineWorkspace(["packages/*", "apps/*", "tooling/*"]); From a538dd2ad139bc37914b46c8cd2b2096587a9658 Mon Sep 17 00:00:00 2001 From: gpalmer27 Date: Wed, 3 Jun 2026 22:28:22 -0400 Subject: [PATCH 2/6] fixed whatever error just happened --- pnpm-lock.yaml | 24 +++++++++--------------- 1 file changed, 9 insertions(+), 15 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d9e3b515..bda163e3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -54,7 +54,7 @@ importers: version: 1.0.9 '@vitejs/plugin-react': specifier: ^6.0.2 - version: 6.0.2(vite@8.0.10(@types/node@25.6.2)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.8.3)) + version: 6.0.2(vite@8.0.10(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.8.3)) '@vitest/coverage-v8': specifier: 4.1.5 version: 4.1.5(vitest@4.1.5) @@ -1305,10 +1305,6 @@ packages: peerDependencies: '@babel/core': ^7.0.0-0 - '@babel/runtime@7.29.2': - resolution: {integrity: sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==} - engines: {node: '>=6.9.0'} - '@babel/runtime@7.29.7': resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} engines: {node: '>=6.9.0'} @@ -11293,8 +11289,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/runtime@7.29.2': {} - '@babel/runtime@7.29.7': {} '@babel/template@7.28.6': @@ -11722,7 +11716,7 @@ snapshots: '@babel/preset-env': 7.29.5(@babel/core@7.29.0) '@babel/preset-react': 7.28.5(@babel/core@7.29.0) '@babel/preset-typescript': 7.28.5(@babel/core@7.29.0) - '@babel/runtime': 7.29.2 + '@babel/runtime': 7.29.7 '@babel/traverse': 7.29.0 '@docusaurus/logger': 3.10.1 '@docusaurus/utils': 3.10.1(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.14))(esbuild@0.28.0)(html-minifier-terser@7.2.0)(postcss@8.5.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -14896,10 +14890,10 @@ snapshots: - utf-8-validate optional: true - '@vitejs/plugin-react@6.0.2(vite@8.0.10(@types/node@25.6.2)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.8.3))': + '@vitejs/plugin-react@6.0.2(vite@8.0.10(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.8.3))': dependencies: '@rolldown/pluginutils': 1.0.1 - vite: 8.0.10(@types/node@25.6.2)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.8.3) + vite: 8.0.10(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.8.3) '@vitest/coverage-v8@4.1.5(vitest@4.1.5)': dependencies: @@ -14913,7 +14907,7 @@ snapshots: obug: 2.1.1 std-env: 4.1.0 tinyrainbow: 3.1.0 - vitest: 4.1.5(@types/node@25.6.2)(@vitest/coverage-v8@4.1.5)(@vitest/ui@4.1.5)(vite@8.0.10(@types/node@25.6.2)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.8.3)) + vitest: 4.1.5(@types/node@25.9.1)(@vitest/coverage-v8@4.1.5)(@vitest/ui@4.1.5)(vite@8.0.10(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.8.3)) '@vitest/expect@4.1.5': dependencies: @@ -19893,7 +19887,7 @@ snapshots: react-loadable-ssr-addon-v5-slorber@1.0.3(@docusaurus/react-loadable@6.0.0(react@18.3.1))(webpack@5.106.2(esbuild@0.28.0)(postcss@8.5.14)): dependencies: - '@babel/runtime': 7.29.2 + '@babel/runtime': 7.29.7 react-loadable: '@docusaurus/react-loadable@6.0.0(react@18.3.1)' webpack: 5.106.2(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.14))(esbuild@0.28.0)(html-minifier-terser@7.2.0)(postcss@8.5.14) @@ -19918,13 +19912,13 @@ snapshots: react-router-config@5.1.1(react-router@5.3.4(react@18.3.1))(react@18.3.1): dependencies: - '@babel/runtime': 7.29.2 + '@babel/runtime': 7.29.7 react: 18.3.1 react-router: 5.3.4(react@18.3.1) react-router-dom@5.3.4(react@18.3.1): dependencies: - '@babel/runtime': 7.29.2 + '@babel/runtime': 7.29.7 history: 4.10.1 loose-envify: 1.4.0 prop-types: 15.8.1 @@ -19935,7 +19929,7 @@ snapshots: react-router@5.3.4(react@18.3.1): dependencies: - '@babel/runtime': 7.29.2 + '@babel/runtime': 7.29.7 history: 4.10.1 hoist-non-react-statics: 3.3.2 loose-envify: 1.4.0 From 50efe525e76fd2cd9e402374c7a9d40e97e99913 Mon Sep 17 00:00:00 2001 From: gpalmer27 Date: Wed, 3 Jun 2026 22:33:35 -0400 Subject: [PATCH 3/6] fixed lint --- apps/web/tsconfig.json | 2 +- packages/ui/tsconfig.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index db6b2902..05c97c27 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -12,5 +12,5 @@ "module": "esnext" }, "include": [".", ".next/types/**/*.ts"], - "exclude": ["node_modules", "coverage"] + "exclude": ["node_modules", "coverage", "vitest.config.ts"] } diff --git a/packages/ui/tsconfig.json b/packages/ui/tsconfig.json index f560e983..73c140b0 100644 --- a/packages/ui/tsconfig.json +++ b/packages/ui/tsconfig.json @@ -6,5 +6,5 @@ "tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json" }, "include": ["*.ts", "src", "tests"], - "exclude": ["node_modules"] + "exclude": ["node_modules", "vitest.config.ts"] } From 8e462a4896e21207d6b2e12bfd3880b55d71c2b5 Mon Sep 17 00:00:00 2001 From: gpalmer27 Date: Wed, 3 Jun 2026 22:38:09 -0400 Subject: [PATCH 4/6] installed testing --- package.json | 4 + pnpm-lock.yaml | 514 ++++++++++++++++++++++++++++++++++++++++++------- 2 files changed, 452 insertions(+), 66 deletions(-) diff --git a/package.json b/package.json index 87c0ac53..056ce88e 100644 --- a/package.json +++ b/package.json @@ -39,11 +39,15 @@ }, "devDependencies": { "@cooper/prettier-config": "workspace:*", + "@testing-library/dom": "^10.4.1", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", "@turbo/gen": "^2.9.14", "@types/estree": "^1.0.9", "@vitejs/plugin-react": "^6.0.2", "@vitest/coverage-v8": "4.1.5", "@vitest/ui": "^4.1.5", + "jsdom": "^25.0.1", "prettier": "catalog:", "turbo": "^2.9.14", "typescript": "catalog:", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bda163e3..92425fe3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -46,6 +46,15 @@ importers: '@cooper/prettier-config': specifier: workspace:* version: link:tooling/prettier + '@testing-library/dom': + specifier: ^10.4.1 + version: 10.4.1 + '@testing-library/jest-dom': + specifier: ^6.9.1 + version: 6.9.1 + '@testing-library/react': + specifier: ^16.3.2 + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.5(react@18.3.1))(react@18.3.1) '@turbo/gen': specifier: ^2.9.14 version: 2.9.16(@types/node@25.9.1) @@ -61,6 +70,9 @@ importers: '@vitest/ui': specifier: ^4.1.5 version: 4.1.5(vitest@4.1.5) + jsdom: + specifier: ^25.0.1 + version: 25.0.1(bufferutil@4.1.0)(utf-8-validate@6.0.6) prettier: specifier: 'catalog:' version: 3.3.3 @@ -72,7 +84,7 @@ importers: version: 5.5.4 vitest: specifier: 'catalog:' - version: 4.1.5(@types/node@25.9.1)(@vitest/coverage-v8@4.1.5)(@vitest/ui@4.1.5)(vite@8.0.10(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.8.3)) + version: 4.1.5(@types/node@25.9.1)(@vitest/coverage-v8@4.1.5)(@vitest/ui@4.1.5)(jsdom@25.0.1(bufferutil@4.1.0)(utf-8-validate@6.0.6))(vite@8.0.10(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.8.3)) apps/auth-proxy: dependencies: @@ -115,10 +127,10 @@ importers: dependencies: '@docusaurus/core': specifier: 3.10.1 - version: 3.10.1(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(bufferutil@4.1.0)(esbuild@0.28.0)(postcss@8.5.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.4) + version: 3.10.1(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(bufferutil@4.1.0)(esbuild@0.28.0)(postcss@8.5.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.4)(utf-8-validate@6.0.6) '@docusaurus/preset-classic': specifier: 3.10.1 - version: 3.10.1(@algolia/client-search@5.52.1)(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(@types/react@19.2.14)(bufferutil@4.1.0)(esbuild@0.28.0)(postcss@8.5.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(search-insights@2.17.3)(typescript@5.5.4) + version: 3.10.1(@algolia/client-search@5.52.1)(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(@types/react@19.2.14)(bufferutil@4.1.0)(esbuild@0.28.0)(postcss@8.5.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(search-insights@2.17.3)(typescript@5.5.4)(utf-8-validate@6.0.6) '@mdx-js/react': specifier: ^3.1.1 version: 3.1.1(@types/react@19.2.14)(react@18.3.1) @@ -644,6 +656,9 @@ importers: packages: + '@adobe/css-tools@4.5.0': + resolution: {integrity: sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==} + '@algolia/abtesting@1.18.1': resolution: {integrity: sha512-aehCadlWOGvrT91KUIZpC0MbB8KBW9yUuvTJFd2xesR7le/IsT4nJUnjCCZ4ZqZCeTcPHPV5mo//fZ5oxcSVYw==} engines: {node: '>= 14.0.0'} @@ -735,6 +750,9 @@ packages: resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} engines: {node: '>=10'} + '@asamuzakjp/css-color@3.2.0': + resolution: {integrity: sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==} + '@auth/core@0.32.0': resolution: {integrity: sha512-3+ssTScBd+1fd0/fscAyQN1tSygXzuhysuVVzB942ggU4mdfiTbv36P0ccVnExKWYJKvu3E2r3/zxXCCAmTOrg==} peerDependencies: @@ -4181,6 +4199,29 @@ packages: peerDependencies: react: ^18 || ^19 + '@testing-library/dom@10.4.1': + resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} + engines: {node: '>=18'} + + '@testing-library/jest-dom@6.9.1': + resolution: {integrity: sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==} + engines: {node: '>=14', npm: '>=6', yarn: '>=1'} + + '@testing-library/react@16.3.2': + resolution: {integrity: sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==} + engines: {node: '>=18'} + peerDependencies: + '@testing-library/dom': ^10.0.0 + '@types/react': ^18.3.3 + '@types/react-dom': ^18.3.0 + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@tootallnate/quickjs-emscripten@0.23.0': resolution: {integrity: sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==} @@ -4243,6 +4284,9 @@ packages: '@tybys/wasm-util@0.10.2': resolution: {integrity: sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==} + '@types/aria-query@5.0.4': + resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} + '@types/body-parser@1.19.6': resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==} @@ -4709,6 +4753,10 @@ packages: resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} engines: {node: '>=8'} + ansi-styles@5.2.0: + resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} + engines: {node: '>=10'} + ansi-styles@6.2.3: resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} engines: {node: '>=12'} @@ -4745,6 +4793,9 @@ packages: resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==} engines: {node: '>=10'} + aria-query@5.3.0: + resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} + aria-query@5.3.2: resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} engines: {node: '>= 0.4'} @@ -4820,6 +4871,9 @@ packages: async@3.2.6: resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==} + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + autoprefixer@10.5.0: resolution: {integrity: sha512-FMhOoZV4+qR6aTUALKX2rEqGG+oyATvwBt9IIzVR5rMa2HRWPkxf+P+PAJLD1I/H5/II+HuZcBJYEFBpq39ong==} engines: {node: ^10 || ^12 || >=14} @@ -5297,6 +5351,10 @@ packages: resolution: {integrity: sha512-VcQB1ziGD0NXrhKxiwyNbCDmRzs/OShMs2GqW2DlU2A/Sd0nQxE1oWDAE5O0ygSx5mgQOn9eIFh7yKPgFRVkPQ==} engines: {node: '>=10'} + combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + comma-separated-tokens@2.0.3: resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==} @@ -5547,6 +5605,9 @@ packages: resolution: {integrity: sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==} engines: {node: '>= 6'} + css.escape@1.5.1: + resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==} + cssdb@8.8.0: resolution: {integrity: sha512-QbLeyz2Bgso1iRlh7IpWk6OKa3lLNGXsujVjDMPl9rOZpxKeiG69icLpbLCFxeURwmcdIfZqQyhlooKJYM4f8Q==} @@ -5583,6 +5644,10 @@ packages: resolution: {integrity: sha512-0LrrStPOdJj+SPCCrGhzryycLjwcgUSHBtxNA8aIDxf0GLsRh1cKYhB00Gd1lDOS4yGH69+SNn13+TWbVHETFQ==} engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0, npm: '>=7.0.0'} + cssstyle@4.6.0: + resolution: {integrity: sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==} + engines: {node: '>=18'} + csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} @@ -5593,6 +5658,10 @@ packages: resolution: {integrity: sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw==} engines: {node: '>= 14'} + data-urls@5.0.0: + resolution: {integrity: sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==} + engines: {node: '>=18'} + data-view-buffer@1.0.2: resolution: {integrity: sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==} engines: {node: '>= 0.4'} @@ -5659,6 +5728,9 @@ packages: supports-color: optional: true + decimal.js@10.6.0: + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + decode-named-character-reference@1.3.0: resolution: {integrity: sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==} @@ -5712,6 +5784,10 @@ packages: resolution: {integrity: sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ==} engines: {node: '>= 14'} + delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} + denque@2.1.0: resolution: {integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==} engines: {node: '>=0.10'} @@ -5774,6 +5850,12 @@ packages: resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==} engines: {node: '>=0.10.0'} + dom-accessibility-api@0.5.16: + resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} + + dom-accessibility-api@0.6.3: + resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==} + dom-converter@0.2.0: resolution: {integrity: sha512-gd3ypIPfOMr9h5jIKq8E3sHOTCjeirnl0WK5ZdS1AW0Odt0b1PaWaHdJ4Qk4klv+YB9aJBS7mESXjFoDQPu6DA==} @@ -6397,6 +6479,10 @@ packages: resolution: {integrity: sha512-yDYSgNMraqvnxiEXO4hi88+YZxaHC6QKzb5N84iRCTDeRO7ZALpir/lVmf/uXUhnwUr2O4HU8s/n6x+yNjQkHw==} engines: {node: '>= 14.17'} + form-data@4.0.5: + resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} + engines: {node: '>= 6'} + format@0.2.2: resolution: {integrity: sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww==} engines: {node: '>=0.4.x'} @@ -6657,6 +6743,10 @@ packages: hpack.js@2.1.6: resolution: {integrity: sha512-zJxVehUdMGIKsRaNt7apO2Gqp0BdqW5yaiGHXXmbpvxgBYVZnAql+BJb4RO5ad2MgpbZKn5G6nMnegrH1FcNYQ==} + html-encoding-sniffer@4.0.0: + resolution: {integrity: sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==} + engines: {node: '>=18'} + html-escaper@2.0.2: resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} @@ -6756,6 +6846,10 @@ packages: resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==} engines: {node: '>=0.10.0'} + iconv-lite@0.6.3: + resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} + engines: {node: '>=0.10.0'} + iconv-lite@0.7.2: resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} engines: {node: '>=0.10.0'} @@ -6998,6 +7092,9 @@ packages: resolution: {integrity: sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==} engines: {node: '>=0.10.0'} + is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + is-reference@1.2.1: resolution: {integrity: sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ==} @@ -7146,6 +7243,15 @@ packages: resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} hasBin: true + jsdom@25.0.1: + resolution: {integrity: sha512-8i7LzZj7BF8uplX+ZyOlIz86V6TAsSs+np6m1kpW9u0JWi4z/1t+FzcK1aek+ybTnAC4KhBL4uXCNT0wcUIeCw==} + engines: {node: '>=18'} + peerDependencies: + canvas: ^2.11.2 + peerDependenciesMeta: + canvas: + optional: true + jsesc@3.1.0: resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} engines: {node: '>=6'} @@ -7373,6 +7479,9 @@ packages: resolution: {integrity: sha512-ozCC6gdQ+glXOQsveKD0YsDy8DSQFjDTz4zyzEHNV5+JP5D62LmfDZ6o1cycFx9ouG940M5dE8C8CTewdj2YWQ==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + lru-cache@10.4.3: + resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + lru-cache@11.3.6: resolution: {integrity: sha512-Gf/KoL3C/MlI7Bt0PGI9I+TeTC/I6r/csU58N4BSNc4lppLBeKsOdFYkK+dX0ABDUMJNfCHTyPpzwwO21Awd3A==} engines: {node: 20 || >=22} @@ -7389,6 +7498,10 @@ packages: peerDependencies: react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc + lz-string@1.5.0: + resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} + hasBin: true + magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} @@ -7666,6 +7779,10 @@ packages: resolution: {integrity: sha512-e5ISH9xMYU0DzrT+jl8q2ze9D6eWBto+I8CNpe+VI+K2J/F/k3PdkdTdz4wvGVH4NTpo+NRYTVIuMQEMMcsLqg==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + min-indent@1.0.1: + resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} + engines: {node: '>=4'} + mini-css-extract-plugin@2.10.2: resolution: {integrity: sha512-AOSS0IdEB95ayVkxn5oGzNQwqAi2J0Jb/kKm43t7H73s8+f5873g0yuj0PNvK4dO75mu5DHg4nlgp4k6Kga8eg==} engines: {node: '>= 12.13.0'} @@ -7857,6 +7974,9 @@ packages: peerDependencies: webpack: ^4.0.0 || ^5.0.0 + nwsapi@2.2.23: + resolution: {integrity: sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ==} + oauth4webapi@2.17.0: resolution: {integrity: sha512-lbC0Z7uzAFNFyzEYRIC+pkSVvDHJTbEW+dYlSBAlCYDe6RxUkJ26bClhk8ocBZip1wfI9uKTe0fm4Ib4RHn6uQ==} @@ -8690,6 +8810,10 @@ packages: pretty-error@4.0.0: resolution: {integrity: sha512-AoJ5YMAcXKYxKhuJGdcvse+Voc6v1RgnsR3nWcYU7q4t6z0Q6T86sv5Zq8VIRbOWWFpvdGE83LtdSMNd+6Y0xw==} + pretty-format@27.5.1: + resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + pretty-format@3.8.0: resolution: {integrity: sha512-WuxUnVtlWL1OfZFQFuqvnvs6MiAGk9UNsBostyBOB0Is9wb5uRESevA6rnl/rkksXaGX3GzZhPup5d6Vp1nFew==} @@ -8830,6 +8954,9 @@ packages: react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} + react-is@17.0.2: + resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} + react-json-view-lite@2.5.0: resolution: {integrity: sha512-tk7o7QG9oYyELWHL8xiMQ8x4WzjCzbWNyig3uexmkLb54r8jO0yH3WCWx8UZS0c49eSA4QUmG5caiRJ8fAn58g==} engines: {node: '>=18'} @@ -8938,6 +9065,10 @@ packages: recma-stringify@1.0.0: resolution: {integrity: sha512-cjwII1MdIIVloKvC9ErQ+OgAtwHBmcZ0Bg4ciz78FtbT8In39aAYbaA7zvxQ61xVMSPE8WxhLwLbhif4Js2C+g==} + redent@3.0.0: + resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} + engines: {node: '>=8'} + redis-errors@1.2.0: resolution: {integrity: sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==} engines: {node: '>=4'} @@ -9100,6 +9231,12 @@ packages: rou3@0.7.12: resolution: {integrity: sha512-iFE4hLDuloSWcD7mjdCDhx2bKcIsYbtOTpfH5MHHLSKMOUyjqQXTeZVa289uuwEGEKFoE/BAPbhaU4B774nceg==} + rrweb-cssom@0.7.1: + resolution: {integrity: sha512-TrEMa7JGdVm0UThDJSx7ddw5nVm3UJS9o9CCIZ72B1vSyEZoziDqBYP3XIoi/12lKrJR8rE3jeFHMok2F/Mnsg==} + + rrweb-cssom@0.8.0: + resolution: {integrity: sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==} + rtlcss@4.3.0: resolution: {integrity: sha512-FI+pHEn7Wc4NqKXMXFM+VAYKEj/mRIcW4h24YVwVtyjI+EqGrLc2Hx/Ny0lrZ21cBWU2goLy36eqMcNj3AQJig==} engines: {node: '>=12.0.0'} @@ -9137,6 +9274,10 @@ packages: resolution: {integrity: sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==} engines: {node: '>=11.0.0'} + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + scheduler@0.23.2: resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==} @@ -9472,6 +9613,10 @@ packages: resolution: {integrity: sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==} engines: {node: '>=6'} + strip-indent@3.0.0: + resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} + engines: {node: '>=8'} + strip-json-comments@2.0.1: resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==} engines: {node: '>=0.10.0'} @@ -9541,6 +9686,9 @@ packages: engines: {node: '>=14.0.0'} hasBin: true + symbol-tree@3.2.4: + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + tagged-tag@1.0.0: resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==} engines: {node: '>=20'} @@ -9674,6 +9822,13 @@ packages: resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} engines: {node: '>=14.0.0'} + tldts-core@6.1.86: + resolution: {integrity: sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==} + + tldts@6.1.86: + resolution: {integrity: sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==} + hasBin: true + to-regex-range@5.0.1: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} @@ -9686,9 +9841,17 @@ packages: resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==} engines: {node: '>=6'} + tough-cookie@5.1.2: + resolution: {integrity: sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==} + engines: {node: '>=16'} + tr46@0.0.3: resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} + tr46@5.1.1: + resolution: {integrity: sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==} + engines: {node: '>=18'} + tree-dump@1.1.0: resolution: {integrity: sha512-rMuvhU4MCDbcbnleZTFezWsaZXRFemSqAM+7jPnzUl1fo9w3YEKOxAeui0fz3OI4EU4hf23iyA7uQRVko+UaBA==} engines: {node: '>=10.0'} @@ -10134,6 +10297,10 @@ packages: jsdom: optional: true + w3c-xmlserializer@5.0.0: + resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} + engines: {node: '>=18'} + watchpack@2.5.1: resolution: {integrity: sha512-Zn5uXdcFNIA1+1Ei5McRd+iRzfhENPCe7LeABkJtNulSxjma+l7ltNx55BWZkRlwRnpOgHqxnjyaDgJnNXnqzg==} engines: {node: '>=10.13.0'} @@ -10150,6 +10317,10 @@ packages: webidl-conversions@3.0.1: resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} + webidl-conversions@7.0.0: + resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==} + engines: {node: '>=12'} + webpack-bundle-analyzer@4.10.2: resolution: {integrity: sha512-vJptkMm9pk5si4Bv922ZbKLV8UTT4zib4FPgXMhgzUny0bfDDkLXAVQs3ly3fS4/TN9ROFtb0NFrm04UXFE/Vw==} engines: {node: '>= 10.13.0'} @@ -10222,6 +10393,19 @@ packages: resolution: {integrity: sha512-OqedPIGOfsDlo31UNwYbCFMSaO9m9G/0faIHj5/dZFDMFqPTcx6UwqyOy3COEaEOg/9VsGIpdqn62W5KhoKSpg==} engines: {node: '>=0.8.0'} + whatwg-encoding@3.1.1: + resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==} + engines: {node: '>=18'} + deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation + + whatwg-mimetype@4.0.0: + resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==} + engines: {node: '>=18'} + + whatwg-url@14.2.0: + resolution: {integrity: sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==} + engines: {node: '>=18'} + whatwg-url@5.0.0: resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} @@ -10336,6 +10520,13 @@ packages: resolution: {integrity: sha512-7rVi2KMfwfWFl+GpPg6m80IVMWXLRjO+PxTq7V2CDhoGak0wzYzFgUY2m4XJ47OGdXd8eLE8EmwfAmdjw7lC1g==} hasBin: true + xml-name-validator@5.0.0: + resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} + engines: {node: '>=18'} + + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + xtend@4.0.2: resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} engines: {node: '>=0.4'} @@ -10414,6 +10605,8 @@ packages: snapshots: + '@adobe/css-tools@4.5.0': {} + '@algolia/abtesting@1.18.1': dependencies: '@algolia/client-common': 5.52.1 @@ -10546,6 +10739,14 @@ snapshots: '@alloc/quick-lru@5.2.0': {} + '@asamuzakjp/css-color@3.2.0': + dependencies: + '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-color-parser': 3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + lru-cache: 10.4.3 + '@auth/core@0.32.0': dependencies: '@panva/hkdf': 1.2.1 @@ -11784,7 +11985,7 @@ snapshots: - uglify-js - webpack-cli - '@docusaurus/core@3.10.1(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(bufferutil@4.1.0)(esbuild@0.28.0)(postcss@8.5.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.4)': + '@docusaurus/core@3.10.1(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(bufferutil@4.1.0)(esbuild@0.28.0)(postcss@8.5.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.4)(utf-8-validate@6.0.6)': dependencies: '@docusaurus/babel': 3.10.1(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.14))(esbuild@0.28.0)(html-minifier-terser@7.2.0)(postcss@8.5.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@docusaurus/bundler': 3.10.1(esbuild@0.28.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.4) @@ -11828,8 +12029,8 @@ snapshots: tslib: 2.8.1 update-notifier: 6.0.2 webpack: 5.106.2(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.14))(esbuild@0.28.0)(html-minifier-terser@7.2.0)(postcss@8.5.14) - webpack-bundle-analyzer: 4.10.2(bufferutil@4.1.0) - webpack-dev-server: 5.2.3(bufferutil@4.1.0)(tslib@2.8.1)(webpack@5.106.2(esbuild@0.28.0)(postcss@8.5.14)) + webpack-bundle-analyzer: 4.10.2(bufferutil@4.1.0)(utf-8-validate@6.0.6) + webpack-dev-server: 5.2.3(bufferutil@4.1.0)(tslib@2.8.1)(utf-8-validate@6.0.6)(webpack@5.106.2(esbuild@0.28.0)(postcss@8.5.14)) webpack-merge: 6.0.1 transitivePeerDependencies: - '@minify-html/node' @@ -11936,13 +12137,13 @@ snapshots: - uglify-js - webpack-cli - '@docusaurus/plugin-content-blog@3.10.1(@docusaurus/plugin-content-docs@3.10.1(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(bufferutil@4.1.0)(esbuild@0.28.0)(postcss@8.5.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.4))(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(bufferutil@4.1.0)(esbuild@0.28.0)(postcss@8.5.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.4)': + '@docusaurus/plugin-content-blog@3.10.1(@docusaurus/plugin-content-docs@3.10.1(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(bufferutil@4.1.0)(esbuild@0.28.0)(postcss@8.5.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.4)(utf-8-validate@6.0.6))(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(bufferutil@4.1.0)(esbuild@0.28.0)(postcss@8.5.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.4)(utf-8-validate@6.0.6)': dependencies: - '@docusaurus/core': 3.10.1(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(bufferutil@4.1.0)(esbuild@0.28.0)(postcss@8.5.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.4) + '@docusaurus/core': 3.10.1(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(bufferutil@4.1.0)(esbuild@0.28.0)(postcss@8.5.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.4)(utf-8-validate@6.0.6) '@docusaurus/logger': 3.10.1 '@docusaurus/mdx-loader': 3.10.1(esbuild@0.28.0)(postcss@8.5.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@docusaurus/plugin-content-docs': 3.10.1(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(bufferutil@4.1.0)(esbuild@0.28.0)(postcss@8.5.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.4) - '@docusaurus/theme-common': 3.10.1(@docusaurus/plugin-content-docs@3.10.1(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(bufferutil@4.1.0)(esbuild@0.28.0)(postcss@8.5.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.4))(esbuild@0.28.0)(postcss@8.5.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@docusaurus/plugin-content-docs': 3.10.1(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(bufferutil@4.1.0)(esbuild@0.28.0)(postcss@8.5.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.4)(utf-8-validate@6.0.6) + '@docusaurus/theme-common': 3.10.1(@docusaurus/plugin-content-docs@3.10.1(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(bufferutil@4.1.0)(esbuild@0.28.0)(postcss@8.5.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.4)(utf-8-validate@6.0.6))(esbuild@0.28.0)(postcss@8.5.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@docusaurus/types': 3.10.1(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.14))(esbuild@0.28.0)(html-minifier-terser@7.2.0)(postcss@8.5.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@docusaurus/utils': 3.10.1(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.14))(esbuild@0.28.0)(html-minifier-terser@7.2.0)(postcss@8.5.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@docusaurus/utils-common': 3.10.1(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.14))(esbuild@0.28.0)(html-minifier-terser@7.2.0)(postcss@8.5.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -11984,13 +12185,13 @@ snapshots: - utf-8-validate - webpack-cli - '@docusaurus/plugin-content-docs@3.10.1(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(bufferutil@4.1.0)(esbuild@0.28.0)(postcss@8.5.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.4)': + '@docusaurus/plugin-content-docs@3.10.1(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(bufferutil@4.1.0)(esbuild@0.28.0)(postcss@8.5.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.4)(utf-8-validate@6.0.6)': dependencies: - '@docusaurus/core': 3.10.1(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(bufferutil@4.1.0)(esbuild@0.28.0)(postcss@8.5.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.4) + '@docusaurus/core': 3.10.1(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(bufferutil@4.1.0)(esbuild@0.28.0)(postcss@8.5.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.4)(utf-8-validate@6.0.6) '@docusaurus/logger': 3.10.1 '@docusaurus/mdx-loader': 3.10.1(esbuild@0.28.0)(postcss@8.5.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@docusaurus/module-type-aliases': 3.10.1(esbuild@0.28.0)(postcss@8.5.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@docusaurus/theme-common': 3.10.1(@docusaurus/plugin-content-docs@3.10.1(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(bufferutil@4.1.0)(esbuild@0.28.0)(postcss@8.5.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.4))(esbuild@0.28.0)(postcss@8.5.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@docusaurus/theme-common': 3.10.1(@docusaurus/plugin-content-docs@3.10.1(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(bufferutil@4.1.0)(esbuild@0.28.0)(postcss@8.5.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.4)(utf-8-validate@6.0.6))(esbuild@0.28.0)(postcss@8.5.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@docusaurus/types': 3.10.1(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.14))(esbuild@0.28.0)(html-minifier-terser@7.2.0)(postcss@8.5.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@docusaurus/utils': 3.10.1(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.14))(esbuild@0.28.0)(html-minifier-terser@7.2.0)(postcss@8.5.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@docusaurus/utils-common': 3.10.1(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.14))(esbuild@0.28.0)(html-minifier-terser@7.2.0)(postcss@8.5.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -12030,9 +12231,9 @@ snapshots: - utf-8-validate - webpack-cli - '@docusaurus/plugin-content-pages@3.10.1(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(bufferutil@4.1.0)(esbuild@0.28.0)(postcss@8.5.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.4)': + '@docusaurus/plugin-content-pages@3.10.1(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(bufferutil@4.1.0)(esbuild@0.28.0)(postcss@8.5.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.4)(utf-8-validate@6.0.6)': dependencies: - '@docusaurus/core': 3.10.1(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(bufferutil@4.1.0)(esbuild@0.28.0)(postcss@8.5.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.4) + '@docusaurus/core': 3.10.1(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(bufferutil@4.1.0)(esbuild@0.28.0)(postcss@8.5.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.4)(utf-8-validate@6.0.6) '@docusaurus/mdx-loader': 3.10.1(esbuild@0.28.0)(postcss@8.5.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@docusaurus/types': 3.10.1(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.14))(esbuild@0.28.0)(html-minifier-terser@7.2.0)(postcss@8.5.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@docusaurus/utils': 3.10.1(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.14))(esbuild@0.28.0)(html-minifier-terser@7.2.0)(postcss@8.5.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -12066,9 +12267,9 @@ snapshots: - utf-8-validate - webpack-cli - '@docusaurus/plugin-css-cascade-layers@3.10.1(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(bufferutil@4.1.0)(esbuild@0.28.0)(postcss@8.5.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.4)': + '@docusaurus/plugin-css-cascade-layers@3.10.1(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(bufferutil@4.1.0)(esbuild@0.28.0)(postcss@8.5.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.4)(utf-8-validate@6.0.6)': dependencies: - '@docusaurus/core': 3.10.1(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(bufferutil@4.1.0)(esbuild@0.28.0)(postcss@8.5.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.4) + '@docusaurus/core': 3.10.1(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(bufferutil@4.1.0)(esbuild@0.28.0)(postcss@8.5.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.4)(utf-8-validate@6.0.6) '@docusaurus/types': 3.10.1(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.14))(esbuild@0.28.0)(html-minifier-terser@7.2.0)(postcss@8.5.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@docusaurus/utils': 3.10.1(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.14))(esbuild@0.28.0)(html-minifier-terser@7.2.0)(postcss@8.5.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@docusaurus/utils-validation': 3.10.1(esbuild@0.28.0)(postcss@8.5.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -12099,9 +12300,9 @@ snapshots: - utf-8-validate - webpack-cli - '@docusaurus/plugin-debug@3.10.1(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(bufferutil@4.1.0)(esbuild@0.28.0)(postcss@8.5.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.4)': + '@docusaurus/plugin-debug@3.10.1(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(bufferutil@4.1.0)(esbuild@0.28.0)(postcss@8.5.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.4)(utf-8-validate@6.0.6)': dependencies: - '@docusaurus/core': 3.10.1(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(bufferutil@4.1.0)(esbuild@0.28.0)(postcss@8.5.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.4) + '@docusaurus/core': 3.10.1(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(bufferutil@4.1.0)(esbuild@0.28.0)(postcss@8.5.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.4)(utf-8-validate@6.0.6) '@docusaurus/types': 3.10.1(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.14))(esbuild@0.28.0)(html-minifier-terser@7.2.0)(postcss@8.5.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@docusaurus/utils': 3.10.1(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.14))(esbuild@0.28.0)(html-minifier-terser@7.2.0)(postcss@8.5.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) fs-extra: 11.3.5 @@ -12133,9 +12334,9 @@ snapshots: - utf-8-validate - webpack-cli - '@docusaurus/plugin-google-analytics@3.10.1(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(bufferutil@4.1.0)(esbuild@0.28.0)(postcss@8.5.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.4)': + '@docusaurus/plugin-google-analytics@3.10.1(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(bufferutil@4.1.0)(esbuild@0.28.0)(postcss@8.5.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.4)(utf-8-validate@6.0.6)': dependencies: - '@docusaurus/core': 3.10.1(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(bufferutil@4.1.0)(esbuild@0.28.0)(postcss@8.5.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.4) + '@docusaurus/core': 3.10.1(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(bufferutil@4.1.0)(esbuild@0.28.0)(postcss@8.5.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.4)(utf-8-validate@6.0.6) '@docusaurus/types': 3.10.1(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.14))(esbuild@0.28.0)(html-minifier-terser@7.2.0)(postcss@8.5.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@docusaurus/utils-validation': 3.10.1(esbuild@0.28.0)(postcss@8.5.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) react: 18.3.1 @@ -12165,9 +12366,9 @@ snapshots: - utf-8-validate - webpack-cli - '@docusaurus/plugin-google-gtag@3.10.1(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(bufferutil@4.1.0)(esbuild@0.28.0)(postcss@8.5.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.4)': + '@docusaurus/plugin-google-gtag@3.10.1(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(bufferutil@4.1.0)(esbuild@0.28.0)(postcss@8.5.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.4)(utf-8-validate@6.0.6)': dependencies: - '@docusaurus/core': 3.10.1(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(bufferutil@4.1.0)(esbuild@0.28.0)(postcss@8.5.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.4) + '@docusaurus/core': 3.10.1(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(bufferutil@4.1.0)(esbuild@0.28.0)(postcss@8.5.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.4)(utf-8-validate@6.0.6) '@docusaurus/types': 3.10.1(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.14))(esbuild@0.28.0)(html-minifier-terser@7.2.0)(postcss@8.5.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@docusaurus/utils-validation': 3.10.1(esbuild@0.28.0)(postcss@8.5.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@types/gtag.js': 0.0.20 @@ -12198,9 +12399,9 @@ snapshots: - utf-8-validate - webpack-cli - '@docusaurus/plugin-google-tag-manager@3.10.1(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(bufferutil@4.1.0)(esbuild@0.28.0)(postcss@8.5.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.4)': + '@docusaurus/plugin-google-tag-manager@3.10.1(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(bufferutil@4.1.0)(esbuild@0.28.0)(postcss@8.5.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.4)(utf-8-validate@6.0.6)': dependencies: - '@docusaurus/core': 3.10.1(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(bufferutil@4.1.0)(esbuild@0.28.0)(postcss@8.5.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.4) + '@docusaurus/core': 3.10.1(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(bufferutil@4.1.0)(esbuild@0.28.0)(postcss@8.5.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.4)(utf-8-validate@6.0.6) '@docusaurus/types': 3.10.1(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.14))(esbuild@0.28.0)(html-minifier-terser@7.2.0)(postcss@8.5.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@docusaurus/utils-validation': 3.10.1(esbuild@0.28.0)(postcss@8.5.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) react: 18.3.1 @@ -12230,9 +12431,9 @@ snapshots: - utf-8-validate - webpack-cli - '@docusaurus/plugin-sitemap@3.10.1(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(bufferutil@4.1.0)(esbuild@0.28.0)(postcss@8.5.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.4)': + '@docusaurus/plugin-sitemap@3.10.1(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(bufferutil@4.1.0)(esbuild@0.28.0)(postcss@8.5.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.4)(utf-8-validate@6.0.6)': dependencies: - '@docusaurus/core': 3.10.1(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(bufferutil@4.1.0)(esbuild@0.28.0)(postcss@8.5.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.4) + '@docusaurus/core': 3.10.1(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(bufferutil@4.1.0)(esbuild@0.28.0)(postcss@8.5.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.4)(utf-8-validate@6.0.6) '@docusaurus/logger': 3.10.1 '@docusaurus/types': 3.10.1(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.14))(esbuild@0.28.0)(html-minifier-terser@7.2.0)(postcss@8.5.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@docusaurus/utils': 3.10.1(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.14))(esbuild@0.28.0)(html-minifier-terser@7.2.0)(postcss@8.5.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -12267,9 +12468,9 @@ snapshots: - utf-8-validate - webpack-cli - '@docusaurus/plugin-svgr@3.10.1(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(bufferutil@4.1.0)(esbuild@0.28.0)(postcss@8.5.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.4)': + '@docusaurus/plugin-svgr@3.10.1(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(bufferutil@4.1.0)(esbuild@0.28.0)(postcss@8.5.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.4)(utf-8-validate@6.0.6)': dependencies: - '@docusaurus/core': 3.10.1(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(bufferutil@4.1.0)(esbuild@0.28.0)(postcss@8.5.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.4) + '@docusaurus/core': 3.10.1(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(bufferutil@4.1.0)(esbuild@0.28.0)(postcss@8.5.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.4)(utf-8-validate@6.0.6) '@docusaurus/types': 3.10.1(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.14))(esbuild@0.28.0)(html-minifier-terser@7.2.0)(postcss@8.5.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@docusaurus/utils': 3.10.1(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.14))(esbuild@0.28.0)(html-minifier-terser@7.2.0)(postcss@8.5.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@docusaurus/utils-validation': 3.10.1(esbuild@0.28.0)(postcss@8.5.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -12303,22 +12504,22 @@ snapshots: - utf-8-validate - webpack-cli - '@docusaurus/preset-classic@3.10.1(@algolia/client-search@5.52.1)(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(@types/react@19.2.14)(bufferutil@4.1.0)(esbuild@0.28.0)(postcss@8.5.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(search-insights@2.17.3)(typescript@5.5.4)': - dependencies: - '@docusaurus/core': 3.10.1(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(bufferutil@4.1.0)(esbuild@0.28.0)(postcss@8.5.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.4) - '@docusaurus/plugin-content-blog': 3.10.1(@docusaurus/plugin-content-docs@3.10.1(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(bufferutil@4.1.0)(esbuild@0.28.0)(postcss@8.5.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.4))(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(bufferutil@4.1.0)(esbuild@0.28.0)(postcss@8.5.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.4) - '@docusaurus/plugin-content-docs': 3.10.1(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(bufferutil@4.1.0)(esbuild@0.28.0)(postcss@8.5.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.4) - '@docusaurus/plugin-content-pages': 3.10.1(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(bufferutil@4.1.0)(esbuild@0.28.0)(postcss@8.5.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.4) - '@docusaurus/plugin-css-cascade-layers': 3.10.1(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(bufferutil@4.1.0)(esbuild@0.28.0)(postcss@8.5.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.4) - '@docusaurus/plugin-debug': 3.10.1(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(bufferutil@4.1.0)(esbuild@0.28.0)(postcss@8.5.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.4) - '@docusaurus/plugin-google-analytics': 3.10.1(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(bufferutil@4.1.0)(esbuild@0.28.0)(postcss@8.5.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.4) - '@docusaurus/plugin-google-gtag': 3.10.1(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(bufferutil@4.1.0)(esbuild@0.28.0)(postcss@8.5.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.4) - '@docusaurus/plugin-google-tag-manager': 3.10.1(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(bufferutil@4.1.0)(esbuild@0.28.0)(postcss@8.5.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.4) - '@docusaurus/plugin-sitemap': 3.10.1(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(bufferutil@4.1.0)(esbuild@0.28.0)(postcss@8.5.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.4) - '@docusaurus/plugin-svgr': 3.10.1(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(bufferutil@4.1.0)(esbuild@0.28.0)(postcss@8.5.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.4) - '@docusaurus/theme-classic': 3.10.1(@types/react@19.2.14)(bufferutil@4.1.0)(esbuild@0.28.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.4) - '@docusaurus/theme-common': 3.10.1(@docusaurus/plugin-content-docs@3.10.1(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(bufferutil@4.1.0)(esbuild@0.28.0)(postcss@8.5.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.4))(esbuild@0.28.0)(postcss@8.5.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@docusaurus/theme-search-algolia': 3.10.1(@algolia/client-search@5.52.1)(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(@types/react@19.2.14)(bufferutil@4.1.0)(esbuild@0.28.0)(postcss@8.5.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(search-insights@2.17.3)(typescript@5.5.4) + '@docusaurus/preset-classic@3.10.1(@algolia/client-search@5.52.1)(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(@types/react@19.2.14)(bufferutil@4.1.0)(esbuild@0.28.0)(postcss@8.5.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(search-insights@2.17.3)(typescript@5.5.4)(utf-8-validate@6.0.6)': + dependencies: + '@docusaurus/core': 3.10.1(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(bufferutil@4.1.0)(esbuild@0.28.0)(postcss@8.5.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.4)(utf-8-validate@6.0.6) + '@docusaurus/plugin-content-blog': 3.10.1(@docusaurus/plugin-content-docs@3.10.1(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(bufferutil@4.1.0)(esbuild@0.28.0)(postcss@8.5.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.4)(utf-8-validate@6.0.6))(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(bufferutil@4.1.0)(esbuild@0.28.0)(postcss@8.5.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.4)(utf-8-validate@6.0.6) + '@docusaurus/plugin-content-docs': 3.10.1(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(bufferutil@4.1.0)(esbuild@0.28.0)(postcss@8.5.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.4)(utf-8-validate@6.0.6) + '@docusaurus/plugin-content-pages': 3.10.1(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(bufferutil@4.1.0)(esbuild@0.28.0)(postcss@8.5.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.4)(utf-8-validate@6.0.6) + '@docusaurus/plugin-css-cascade-layers': 3.10.1(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(bufferutil@4.1.0)(esbuild@0.28.0)(postcss@8.5.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.4)(utf-8-validate@6.0.6) + '@docusaurus/plugin-debug': 3.10.1(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(bufferutil@4.1.0)(esbuild@0.28.0)(postcss@8.5.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.4)(utf-8-validate@6.0.6) + '@docusaurus/plugin-google-analytics': 3.10.1(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(bufferutil@4.1.0)(esbuild@0.28.0)(postcss@8.5.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.4)(utf-8-validate@6.0.6) + '@docusaurus/plugin-google-gtag': 3.10.1(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(bufferutil@4.1.0)(esbuild@0.28.0)(postcss@8.5.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.4)(utf-8-validate@6.0.6) + '@docusaurus/plugin-google-tag-manager': 3.10.1(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(bufferutil@4.1.0)(esbuild@0.28.0)(postcss@8.5.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.4)(utf-8-validate@6.0.6) + '@docusaurus/plugin-sitemap': 3.10.1(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(bufferutil@4.1.0)(esbuild@0.28.0)(postcss@8.5.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.4)(utf-8-validate@6.0.6) + '@docusaurus/plugin-svgr': 3.10.1(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(bufferutil@4.1.0)(esbuild@0.28.0)(postcss@8.5.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.4)(utf-8-validate@6.0.6) + '@docusaurus/theme-classic': 3.10.1(@types/react@19.2.14)(bufferutil@4.1.0)(esbuild@0.28.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.4)(utf-8-validate@6.0.6) + '@docusaurus/theme-common': 3.10.1(@docusaurus/plugin-content-docs@3.10.1(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(bufferutil@4.1.0)(esbuild@0.28.0)(postcss@8.5.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.4)(utf-8-validate@6.0.6))(esbuild@0.28.0)(postcss@8.5.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@docusaurus/theme-search-algolia': 3.10.1(@algolia/client-search@5.52.1)(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(@types/react@19.2.14)(bufferutil@4.1.0)(esbuild@0.28.0)(postcss@8.5.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(search-insights@2.17.3)(typescript@5.5.4)(utf-8-validate@6.0.6) '@docusaurus/types': 3.10.1(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.14))(esbuild@0.28.0)(html-minifier-terser@7.2.0)(postcss@8.5.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) react: 18.3.1 react-dom: 18.3.1(react@18.3.1) @@ -12354,16 +12555,16 @@ snapshots: '@types/react': 18.3.28 react: 18.3.1 - '@docusaurus/theme-classic@3.10.1(@types/react@19.2.14)(bufferutil@4.1.0)(esbuild@0.28.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.4)': + '@docusaurus/theme-classic@3.10.1(@types/react@19.2.14)(bufferutil@4.1.0)(esbuild@0.28.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.4)(utf-8-validate@6.0.6)': dependencies: - '@docusaurus/core': 3.10.1(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(bufferutil@4.1.0)(esbuild@0.28.0)(postcss@8.5.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.4) + '@docusaurus/core': 3.10.1(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(bufferutil@4.1.0)(esbuild@0.28.0)(postcss@8.5.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.4)(utf-8-validate@6.0.6) '@docusaurus/logger': 3.10.1 '@docusaurus/mdx-loader': 3.10.1(esbuild@0.28.0)(postcss@8.5.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@docusaurus/module-type-aliases': 3.10.1(esbuild@0.28.0)(postcss@8.5.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@docusaurus/plugin-content-blog': 3.10.1(@docusaurus/plugin-content-docs@3.10.1(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(bufferutil@4.1.0)(esbuild@0.28.0)(postcss@8.5.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.4))(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(bufferutil@4.1.0)(esbuild@0.28.0)(postcss@8.5.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.4) - '@docusaurus/plugin-content-docs': 3.10.1(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(bufferutil@4.1.0)(esbuild@0.28.0)(postcss@8.5.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.4) - '@docusaurus/plugin-content-pages': 3.10.1(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(bufferutil@4.1.0)(esbuild@0.28.0)(postcss@8.5.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.4) - '@docusaurus/theme-common': 3.10.1(@docusaurus/plugin-content-docs@3.10.1(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(bufferutil@4.1.0)(esbuild@0.28.0)(postcss@8.5.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.4))(esbuild@0.28.0)(postcss@8.5.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@docusaurus/plugin-content-blog': 3.10.1(@docusaurus/plugin-content-docs@3.10.1(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(bufferutil@4.1.0)(esbuild@0.28.0)(postcss@8.5.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.4)(utf-8-validate@6.0.6))(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(bufferutil@4.1.0)(esbuild@0.28.0)(postcss@8.5.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.4)(utf-8-validate@6.0.6) + '@docusaurus/plugin-content-docs': 3.10.1(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(bufferutil@4.1.0)(esbuild@0.28.0)(postcss@8.5.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.4)(utf-8-validate@6.0.6) + '@docusaurus/plugin-content-pages': 3.10.1(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(bufferutil@4.1.0)(esbuild@0.28.0)(postcss@8.5.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.4)(utf-8-validate@6.0.6) + '@docusaurus/theme-common': 3.10.1(@docusaurus/plugin-content-docs@3.10.1(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(bufferutil@4.1.0)(esbuild@0.28.0)(postcss@8.5.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.4)(utf-8-validate@6.0.6))(esbuild@0.28.0)(postcss@8.5.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@docusaurus/theme-translations': 3.10.1 '@docusaurus/types': 3.10.1(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.14))(esbuild@0.28.0)(html-minifier-terser@7.2.0)(postcss@8.5.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@docusaurus/utils': 3.10.1(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.14))(esbuild@0.28.0)(html-minifier-terser@7.2.0)(postcss@8.5.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -12407,11 +12608,11 @@ snapshots: - utf-8-validate - webpack-cli - '@docusaurus/theme-common@3.10.1(@docusaurus/plugin-content-docs@3.10.1(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(bufferutil@4.1.0)(esbuild@0.28.0)(postcss@8.5.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.4))(esbuild@0.28.0)(postcss@8.5.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + '@docusaurus/theme-common@3.10.1(@docusaurus/plugin-content-docs@3.10.1(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(bufferutil@4.1.0)(esbuild@0.28.0)(postcss@8.5.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.4)(utf-8-validate@6.0.6))(esbuild@0.28.0)(postcss@8.5.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@docusaurus/mdx-loader': 3.10.1(esbuild@0.28.0)(postcss@8.5.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@docusaurus/module-type-aliases': 3.10.1(esbuild@0.28.0)(postcss@8.5.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@docusaurus/plugin-content-docs': 3.10.1(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(bufferutil@4.1.0)(esbuild@0.28.0)(postcss@8.5.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.4) + '@docusaurus/plugin-content-docs': 3.10.1(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(bufferutil@4.1.0)(esbuild@0.28.0)(postcss@8.5.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.4)(utf-8-validate@6.0.6) '@docusaurus/utils': 3.10.1(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.14))(esbuild@0.28.0)(html-minifier-terser@7.2.0)(postcss@8.5.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@docusaurus/utils-common': 3.10.1(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.14))(esbuild@0.28.0)(html-minifier-terser@7.2.0)(postcss@8.5.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@types/history': 4.7.11 @@ -12440,14 +12641,14 @@ snapshots: - uglify-js - webpack-cli - '@docusaurus/theme-search-algolia@3.10.1(@algolia/client-search@5.52.1)(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(@types/react@19.2.14)(bufferutil@4.1.0)(esbuild@0.28.0)(postcss@8.5.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(search-insights@2.17.3)(typescript@5.5.4)': + '@docusaurus/theme-search-algolia@3.10.1(@algolia/client-search@5.52.1)(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(@types/react@19.2.14)(bufferutil@4.1.0)(esbuild@0.28.0)(postcss@8.5.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(search-insights@2.17.3)(typescript@5.5.4)(utf-8-validate@6.0.6)': dependencies: '@algolia/autocomplete-core': 1.19.8(@algolia/client-search@5.52.1)(algoliasearch@5.52.1)(search-insights@2.17.3) '@docsearch/react': 4.6.3(@algolia/client-search@5.52.1)(@types/react@19.2.14)(algoliasearch@5.52.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(search-insights@2.17.3) - '@docusaurus/core': 3.10.1(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(bufferutil@4.1.0)(esbuild@0.28.0)(postcss@8.5.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.4) + '@docusaurus/core': 3.10.1(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(bufferutil@4.1.0)(esbuild@0.28.0)(postcss@8.5.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.4)(utf-8-validate@6.0.6) '@docusaurus/logger': 3.10.1 - '@docusaurus/plugin-content-docs': 3.10.1(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(bufferutil@4.1.0)(esbuild@0.28.0)(postcss@8.5.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.4) - '@docusaurus/theme-common': 3.10.1(@docusaurus/plugin-content-docs@3.10.1(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(bufferutil@4.1.0)(esbuild@0.28.0)(postcss@8.5.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.4))(esbuild@0.28.0)(postcss@8.5.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@docusaurus/plugin-content-docs': 3.10.1(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(bufferutil@4.1.0)(esbuild@0.28.0)(postcss@8.5.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.4)(utf-8-validate@6.0.6) + '@docusaurus/theme-common': 3.10.1(@docusaurus/plugin-content-docs@3.10.1(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(bufferutil@4.1.0)(esbuild@0.28.0)(postcss@8.5.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.4)(utf-8-validate@6.0.6))(esbuild@0.28.0)(postcss@8.5.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@docusaurus/theme-translations': 3.10.1 '@docusaurus/utils': 3.10.1(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.14))(esbuild@0.28.0)(html-minifier-terser@7.2.0)(postcss@8.5.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@docusaurus/utils-validation': 3.10.1(esbuild@0.28.0)(postcss@8.5.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -14498,6 +14699,36 @@ snapshots: '@tanstack/query-core': 5.100.9 react: 18.3.1 + '@testing-library/dom@10.4.1': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/runtime': 7.29.7 + '@types/aria-query': 5.0.4 + aria-query: 5.3.0 + dom-accessibility-api: 0.5.16 + lz-string: 1.5.0 + picocolors: 1.1.1 + pretty-format: 27.5.1 + + '@testing-library/jest-dom@6.9.1': + dependencies: + '@adobe/css-tools': 4.5.0 + aria-query: 5.3.2 + css.escape: 1.5.1 + dom-accessibility-api: 0.6.3 + picocolors: 1.1.1 + redent: 3.0.0 + + '@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.5(react@18.3.1))(react@18.3.1)': + dependencies: + '@babel/runtime': 7.29.7 + '@testing-library/dom': 10.4.1 + react: 18.3.1 + react-dom: 19.2.5(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.28 + '@types/react-dom': 18.3.7(@types/react@18.3.28) + '@tootallnate/quickjs-emscripten@0.23.0': {} '@trpc/client@11.17.0(@trpc/server@11.17.0(typescript@5.5.4))(typescript@5.5.4)': @@ -14547,6 +14778,8 @@ snapshots: tslib: 2.8.1 optional: true + '@types/aria-query@5.0.4': {} + '@types/body-parser@1.19.6': dependencies: '@types/connect': 3.4.38 @@ -14907,7 +15140,7 @@ snapshots: obug: 2.1.1 std-env: 4.1.0 tinyrainbow: 3.1.0 - vitest: 4.1.5(@types/node@25.9.1)(@vitest/coverage-v8@4.1.5)(@vitest/ui@4.1.5)(vite@8.0.10(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.8.3)) + vitest: 4.1.5(@types/node@25.9.1)(@vitest/coverage-v8@4.1.5)(@vitest/ui@4.1.5)(jsdom@25.0.1(bufferutil@4.1.0)(utf-8-validate@6.0.6))(vite@8.0.10(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.8.3)) '@vitest/expect@4.1.5': dependencies: @@ -14953,7 +15186,7 @@ snapshots: sirv: 3.0.2 tinyglobby: 0.2.16 tinyrainbow: 3.1.0 - vitest: 4.1.5(@types/node@25.9.1)(@vitest/coverage-v8@4.1.5)(@vitest/ui@4.1.5)(vite@8.0.10(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.8.3)) + vitest: 4.1.5(@types/node@25.9.1)(@vitest/coverage-v8@4.1.5)(@vitest/ui@4.1.5)(jsdom@25.0.1(bufferutil@4.1.0)(utf-8-validate@6.0.6))(vite@8.0.10(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.8.3)) '@vitest/utils@4.1.5': dependencies: @@ -15142,6 +15375,8 @@ snapshots: dependencies: color-convert: 2.0.1 + ansi-styles@5.2.0: {} + ansi-styles@6.2.3: {} ansis@3.17.0: {} @@ -15189,6 +15424,10 @@ snapshots: dependencies: tslib: 2.8.1 + aria-query@5.3.0: + dependencies: + dequal: 2.0.3 + aria-query@5.3.2: {} array-buffer-byte-length@1.0.2: @@ -15290,6 +15529,8 @@ snapshots: async@3.2.6: {} + asynckit@0.4.0: {} + autoprefixer@10.5.0(postcss@8.5.14): dependencies: browserslist: 4.28.2 @@ -15429,7 +15670,7 @@ snapshots: next: 16.2.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1) react: 18.3.1 react-dom: 18.3.1(react@18.3.1) - vitest: 4.1.5(@types/node@25.9.1)(@vitest/coverage-v8@4.1.5)(@vitest/ui@4.1.5)(vite@8.0.10(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.8.3)) + vitest: 4.1.5(@types/node@25.9.1)(@vitest/coverage-v8@4.1.5)(@vitest/ui@4.1.5)(jsdom@25.0.1(bufferutil@4.1.0)(utf-8-validate@6.0.6))(vite@8.0.10(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.8.3)) transitivePeerDependencies: - '@cloudflare/workers-types' - '@opentelemetry/api' @@ -15762,6 +16003,10 @@ snapshots: combine-promises@1.2.0: {} + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + comma-separated-tokens@2.0.3: {} commander@10.0.1: {} @@ -15990,6 +16235,8 @@ snapshots: css-what@6.2.2: {} + css.escape@1.5.1: {} + cssdb@8.8.0: {} cssesc@3.0.0: {} @@ -16053,12 +16300,22 @@ snapshots: dependencies: css-tree: 2.2.1 + cssstyle@4.6.0: + dependencies: + '@asamuzakjp/css-color': 3.2.0 + rrweb-cssom: 0.8.0 + csstype@3.2.3: {} damerau-levenshtein@1.0.8: {} data-uri-to-buffer@6.0.2: {} + data-urls@5.0.0: + dependencies: + whatwg-mimetype: 4.0.0 + whatwg-url: 14.2.0 + data-view-buffer@1.0.2: dependencies: call-bound: 1.0.4 @@ -16097,6 +16354,8 @@ snapshots: dependencies: ms: 2.1.3 + decimal.js@10.6.0: {} + decode-named-character-reference@1.3.0: dependencies: character-entities: 2.0.2 @@ -16144,6 +16403,8 @@ snapshots: escodegen: 2.1.0 esprima: 4.0.1 + delayed-stream@1.0.0: {} + denque@2.1.0: {} depd@1.1.2: {} @@ -16191,6 +16452,10 @@ snapshots: dependencies: esutils: 2.0.3 + dom-accessibility-api@0.5.16: {} + + dom-accessibility-api@0.6.3: {} + dom-converter@0.2.0: dependencies: utila: 0.4.0 @@ -16999,6 +17264,14 @@ snapshots: form-data-encoder@2.1.4: {} + form-data@4.0.5: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.3 + mime-types: 2.1.35 + format@0.2.2: {} forwarded@0.2.0: {} @@ -17354,6 +17627,10 @@ snapshots: readable-stream: 2.3.8 wbuf: 1.7.3 + html-encoding-sniffer@4.0.0: + dependencies: + whatwg-encoding: 3.1.1 + html-escaper@2.0.2: {} html-minifier-terser@6.1.0: @@ -17477,6 +17754,10 @@ snapshots: dependencies: safer-buffer: 2.1.2 + iconv-lite@0.6.3: + dependencies: + safer-buffer: 2.1.2 + iconv-lite@0.7.2: dependencies: safer-buffer: 2.1.2 @@ -17677,6 +17958,8 @@ snapshots: dependencies: isobject: 3.0.1 + is-potential-custom-element-name@1.0.1: {} + is-reference@1.2.1: dependencies: '@types/estree': 1.0.9 @@ -17827,6 +18110,34 @@ snapshots: dependencies: argparse: 2.0.1 + jsdom@25.0.1(bufferutil@4.1.0)(utf-8-validate@6.0.6): + dependencies: + cssstyle: 4.6.0 + data-urls: 5.0.0 + decimal.js: 10.6.0 + form-data: 4.0.5 + html-encoding-sniffer: 4.0.0 + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 + is-potential-custom-element-name: 1.0.1 + nwsapi: 2.2.23 + parse5: 7.3.0 + rrweb-cssom: 0.7.1 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 5.1.2 + w3c-xmlserializer: 5.0.0 + webidl-conversions: 7.0.0 + whatwg-encoding: 3.1.1 + whatwg-mimetype: 4.0.0 + whatwg-url: 14.2.0 + ws: 8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + jsesc@3.1.0: {} json-buffer@3.0.1: {} @@ -18024,6 +18335,8 @@ snapshots: lowercase-keys@3.0.0: {} + lru-cache@10.4.3: {} + lru-cache@11.3.6: {} lru-cache@5.1.1: @@ -18036,6 +18349,8 @@ snapshots: dependencies: react: 18.3.1 + lz-string@1.5.0: {} + magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -18603,6 +18918,8 @@ snapshots: mimic-response@4.0.0: {} + min-indent@1.0.1: {} + mini-css-extract-plugin@2.10.2(webpack@5.106.2(esbuild@0.28.0)(postcss@8.5.14)): dependencies: schema-utils: 4.3.3 @@ -18865,6 +19182,8 @@ snapshots: schema-utils: 3.3.0 webpack: 5.106.2(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.14))(esbuild@0.28.0)(html-minifier-terser@7.2.0)(postcss@8.5.14) + nwsapi@2.2.23: {} + oauth4webapi@2.17.0: {} object-assign@4.1.1: {} @@ -19719,6 +20038,12 @@ snapshots: lodash: 4.18.1 renderkid: 3.0.0 + pretty-format@27.5.1: + dependencies: + ansi-regex: 5.0.1 + ansi-styles: 5.2.0 + react-is: 17.0.2 + pretty-format@3.8.0: {} pretty-time@1.1.0: {} @@ -19881,6 +20206,8 @@ snapshots: react-is@16.13.1: {} + react-is@17.0.2: {} + react-json-view-lite@2.5.0(react@18.3.1): dependencies: react: 18.3.1 @@ -20026,6 +20353,11 @@ snapshots: unified: 11.0.5 vfile: 6.0.3 + redent@3.0.0: + dependencies: + indent-string: 4.0.0 + strip-indent: 3.0.0 + redis-errors@1.2.0: {} redis-parser@3.0.0: @@ -20280,6 +20612,10 @@ snapshots: rou3@0.7.12: {} + rrweb-cssom@0.7.1: {} + + rrweb-cssom@0.8.0: {} + rtlcss@4.3.0: dependencies: escalade: 3.2.0 @@ -20320,6 +20656,10 @@ snapshots: sax@1.6.0: {} + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 + scheduler@0.23.2: dependencies: loose-envify: 1.4.0 @@ -20782,6 +21122,10 @@ snapshots: strip-final-newline@2.0.0: {} + strip-indent@3.0.0: + dependencies: + min-indent: 1.0.1 + strip-json-comments@2.0.1: {} strip-json-comments@3.1.1: {} @@ -20847,6 +21191,8 @@ snapshots: picocolors: 1.1.1 sax: 1.6.0 + symbol-tree@3.2.4: {} + tagged-tag@1.0.0: {} tailwind-merge@2.6.1: {} @@ -20985,6 +21331,12 @@ snapshots: tinyrainbow@3.1.0: {} + tldts-core@6.1.86: {} + + tldts@6.1.86: + dependencies: + tldts-core: 6.1.86 + to-regex-range@5.0.1: dependencies: is-number: 7.0.0 @@ -20993,8 +21345,16 @@ snapshots: totalist@3.0.1: {} + tough-cookie@5.1.2: + dependencies: + tldts: 6.1.86 + tr46@0.0.3: {} + tr46@5.1.1: + dependencies: + punycode: 2.3.1 + tree-dump@1.1.0(tslib@2.8.1): dependencies: tslib: 2.8.1 @@ -21375,7 +21735,7 @@ snapshots: tsx: 4.21.0 yaml: 2.8.3 - vitest@4.1.5(@types/node@25.9.1)(@vitest/coverage-v8@4.1.5)(@vitest/ui@4.1.5)(vite@8.0.10(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.8.3)): + vitest@4.1.5(@types/node@25.9.1)(@vitest/coverage-v8@4.1.5)(@vitest/ui@4.1.5)(jsdom@25.0.1(bufferutil@4.1.0)(utf-8-validate@6.0.6))(vite@8.0.10(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.8.3)): dependencies: '@vitest/expect': 4.1.5 '@vitest/mocker': 4.1.5(vite@8.0.10(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.8.3)) @@ -21401,9 +21761,14 @@ snapshots: '@types/node': 25.9.1 '@vitest/coverage-v8': 4.1.5(vitest@4.1.5) '@vitest/ui': 4.1.5(vitest@4.1.5) + jsdom: 25.0.1(bufferutil@4.1.0)(utf-8-validate@6.0.6) transitivePeerDependencies: - msw + w3c-xmlserializer@5.0.0: + dependencies: + xml-name-validator: 5.0.0 + watchpack@2.5.1: dependencies: glob-to-regexp: 0.4.1 @@ -21419,7 +21784,9 @@ snapshots: webidl-conversions@3.0.1: {} - webpack-bundle-analyzer@4.10.2(bufferutil@4.1.0): + webidl-conversions@7.0.0: {} + + webpack-bundle-analyzer@4.10.2(bufferutil@4.1.0)(utf-8-validate@6.0.6): dependencies: '@discoveryjs/json-ext': 0.5.7 acorn: 8.16.0 @@ -21432,7 +21799,7 @@ snapshots: opener: 1.5.2 picocolors: 1.1.1 sirv: 2.0.4 - ws: 7.5.10(bufferutil@4.1.0) + ws: 7.5.10(bufferutil@4.1.0)(utf-8-validate@6.0.6) transitivePeerDependencies: - bufferutil - utf-8-validate @@ -21450,7 +21817,7 @@ snapshots: transitivePeerDependencies: - tslib - webpack-dev-server@5.2.3(bufferutil@4.1.0)(tslib@2.8.1)(webpack@5.106.2(esbuild@0.28.0)(postcss@8.5.14)): + webpack-dev-server@5.2.3(bufferutil@4.1.0)(tslib@2.8.1)(utf-8-validate@6.0.6)(webpack@5.106.2(esbuild@0.28.0)(postcss@8.5.14)): dependencies: '@types/bonjour': 3.5.13 '@types/connect-history-api-fallback': 1.5.4 @@ -21562,6 +21929,17 @@ snapshots: websocket-extensions@0.1.4: {} + whatwg-encoding@3.1.1: + dependencies: + iconv-lite: 0.6.3 + + whatwg-mimetype@4.0.0: {} + + whatwg-url@14.2.0: + dependencies: + tr46: 5.1.1 + webidl-conversions: 7.0.0 + whatwg-url@5.0.0: dependencies: tr46: 0.0.3 @@ -21658,9 +22036,10 @@ snapshots: signal-exit: 3.0.7 typedarray-to-buffer: 3.1.5 - ws@7.5.10(bufferutil@4.1.0): + ws@7.5.10(bufferutil@4.1.0)(utf-8-validate@6.0.6): optionalDependencies: bufferutil: 4.1.0 + utf-8-validate: 6.0.6 ws@8.20.0(bufferutil@4.1.0)(utf-8-validate@6.0.6): optionalDependencies: @@ -21671,7 +22050,6 @@ snapshots: optionalDependencies: bufferutil: 4.1.0 utf-8-validate: 6.0.6 - optional: true wsl-utils@0.1.0: dependencies: @@ -21688,6 +22066,10 @@ snapshots: dependencies: sax: 1.6.0 + xml-name-validator@5.0.0: {} + + xmlchars@2.2.0: {} + xtend@4.0.2: optional: true From 3a402c3c858c29a2de513ffdc22ccab4fe2a1c79 Mon Sep 17 00:00:00 2001 From: gpalmer27 Date: Wed, 3 Jun 2026 22:42:15 -0400 Subject: [PATCH 5/6] updated dep --- package.json | 2 + pnpm-lock.yaml | 285 ++++++++++++++++++++++++------------------------- 2 files changed, 142 insertions(+), 145 deletions(-) diff --git a/package.json b/package.json index 056ce88e..81f85a43 100644 --- a/package.json +++ b/package.json @@ -62,6 +62,8 @@ "picomatch": "^2.3.2", "@types/react": "^18.3.3", "@types/react-dom": "^18.3.0", + "react": "18.3.1", + "react-dom": "18.3.1", "serialize-javascript": "^7.0.5", "glob@^10.0.0": "^12.0.0" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 92425fe3..1a38a835 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -21,13 +21,6 @@ catalogs: zod: specifier: ^3.24.0 version: 3.24.4 - react18: - react: - specifier: 18.3.1 - version: 18.3.1 - react-dom: - specifier: 18.3.1 - version: 18.3.1 overrides: flatted: ^3.4.2 @@ -36,6 +29,8 @@ overrides: picomatch: ^2.3.2 '@types/react': ^18.3.3 '@types/react-dom': ^18.3.0 + react: 18.3.1 + react-dom: 18.3.1 serialize-javascript: ^7.0.5 glob@^10.0.0: ^12.0.0 @@ -141,10 +136,10 @@ importers: specifier: ^2.4.1 version: 2.4.1(react@18.3.1) react: - specifier: catalog:react18 + specifier: 18.3.1 version: 18.3.1 react-dom: - specifier: catalog:react18 + specifier: 18.3.1 version: 18.3.1(react@18.3.1) devDependencies: '@docusaurus/module-type-aliases': @@ -217,10 +212,10 @@ importers: specifier: ^16.2.6 version: 16.2.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1) react: - specifier: catalog:react18 + specifier: 18.3.1 version: 18.3.1 react-dom: - specifier: catalog:react18 + specifier: 18.3.1 version: 18.3.1(react@18.3.1) react-hook-form: specifier: ^7.75.0 @@ -342,10 +337,10 @@ importers: specifier: ^16.2.6 version: 16.2.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1) react: - specifier: catalog:react18 + specifier: 18.3.1 version: 18.3.1 react-dom: - specifier: catalog:react18 + specifier: 18.3.1 version: 18.3.1(react@18.3.1) zod: specifier: 'catalog:' @@ -520,7 +515,7 @@ importers: specifier: 'catalog:' version: 3.3.3 react: - specifier: catalog:react18 + specifier: 18.3.1 version: 18.3.1 tailwindcss: specifier: ^3.4.19 @@ -1738,8 +1733,8 @@ packages: resolution: {integrity: sha512-rUOujwIpxJRgD7+kicVsI3D5sqBvdiRTquzWBpTEXZs8ZXfGbfzpus5HqumaNYTppN2HvH8E2yNuRwYdHJeOlA==} peerDependencies: '@types/react': ^18.3.3 - react: '>= 16.8.0 < 20.0.0' - react-dom: '>= 16.8.0 < 20.0.0' + react: 18.3.1 + react-dom: 18.3.1 peerDependenciesMeta: '@types/react': optional: true @@ -1755,8 +1750,8 @@ packages: resolution: {integrity: sha512-Bg2wdDsoQVlNCcEKuEJAU04tvHCqgx8rIu+uIoM4pRtcx3TBKJuXutJik3LTA8LRc9YEyHkrYUrmcC0D7BYf+g==} peerDependencies: '@types/react': ^18.3.3 - react: '>= 16.8.0 < 20.0.0' - react-dom: '>= 16.8.0 < 20.0.0' + react: 18.3.1 + react-dom: 18.3.1 search-insights: '>= 1 < 3' peerDependenciesMeta: '@types/react': @@ -1788,8 +1783,8 @@ packages: peerDependencies: '@docusaurus/faster': '*' '@mdx-js/react': ^3.0.0 - react: ^18.0.0 || ^19.0.0 - react-dom: ^18.0.0 || ^19.0.0 + react: 18.3.1 + react-dom: 18.3.1 peerDependenciesMeta: '@docusaurus/faster': optional: true @@ -1806,36 +1801,36 @@ packages: resolution: {integrity: sha512-GRmeb/wQ+iXRrFwcHBfgQhrJxGElgCsoTWZYDhccjsZVne1p8MK/EpQVIloXttz76TCe78kKD5AEG9n1xc1oxQ==} engines: {node: '>=20.0'} peerDependencies: - react: ^18.0.0 || ^19.0.0 - react-dom: ^18.0.0 || ^19.0.0 + react: 18.3.1 + react-dom: 18.3.1 '@docusaurus/module-type-aliases@3.10.1': resolution: {integrity: sha512-YoOZKUdGlp8xSYhuAkGdSo5Ydkbq4V4eK3sD8v0a2hloxCWdQbNBhkc+Ko9QyjpESc0BYcIGM5iHVAy5hdFV6w==} peerDependencies: - react: '*' - react-dom: '*' + react: 18.3.1 + react-dom: 18.3.1 '@docusaurus/plugin-content-blog@3.10.1': resolution: {integrity: sha512-mmkgE6Q2+K74tnkou7tXlpDLvoCU/qkSa2GSQ3XUiHWvcebCoDQzS670RR3tO8PmaWlIyWWISYWzZLuMfxunRA==} engines: {node: '>=20.0'} peerDependencies: '@docusaurus/plugin-content-docs': '*' - react: ^18.0.0 || ^19.0.0 - react-dom: ^18.0.0 || ^19.0.0 + react: 18.3.1 + react-dom: 18.3.1 '@docusaurus/plugin-content-docs@3.10.1': resolution: {integrity: sha512-2jRVrtzjf8LClGTHQlwlwuD3wQXRx3WEoF7XUarJ8Ou+0onV+SLtejsyfY9JLpfUh9hPhXM4pbBGkyAY4Bi3HQ==} engines: {node: '>=20.0'} peerDependencies: - react: ^18.0.0 || ^19.0.0 - react-dom: ^18.0.0 || ^19.0.0 + react: 18.3.1 + react-dom: 18.3.1 '@docusaurus/plugin-content-pages@3.10.1': resolution: {integrity: sha512-huJpaRPMl42nsFwuCXvV8bVDj2MazuwRJIUylI/RSlmZeJssVoZXeCjVf1y+1Drtpa9SKcdGn8yoJ76IRJijtw==} engines: {node: '>=20.0'} peerDependencies: - react: ^18.0.0 || ^19.0.0 - react-dom: ^18.0.0 || ^19.0.0 + react: 18.3.1 + react-dom: 18.3.1 '@docusaurus/plugin-css-cascade-layers@3.10.1': resolution: {integrity: sha512-r//fn+MNHkE1wCof8T29VAQezt1enGCpsFxoziBbvLgBM4JfXN2P3rxrBaavHmvLvm7lYkpJeitcDthwnmWCTw==} @@ -1845,77 +1840,77 @@ packages: resolution: {integrity: sha512-9KqOpKNfAyqGZykRb9LhIT/vyRF6sm/ykhjj/39JvaJahDS+jZJE0Z1Wfz9q3DUNDTMNN0Q7u/kk4rKKU+IJuA==} engines: {node: '>=20.0'} peerDependencies: - react: ^18.0.0 || ^19.0.0 - react-dom: ^18.0.0 || ^19.0.0 + react: 18.3.1 + react-dom: 18.3.1 '@docusaurus/plugin-google-analytics@3.10.1': resolution: {integrity: sha512-8o0P1KtmgdYQHH+oInitPpRWI0Of5XednAX4+DMhQNSmGSRNrsEEHg1ebv35m9AgRClfAytCJ5jA9KvcASTyuA==} engines: {node: '>=20.0'} peerDependencies: - react: ^18.0.0 || ^19.0.0 - react-dom: ^18.0.0 || ^19.0.0 + react: 18.3.1 + react-dom: 18.3.1 '@docusaurus/plugin-google-gtag@3.10.1': resolution: {integrity: sha512-pu3xIUo5o/zCMLfUY9BO5KOwSH0zIsAGyFRPvXHayFSA5XIhCU/SFuB0g0ZNjFn9niZLCaNvoeAuOGFJZq0fdw==} engines: {node: '>=20.0'} peerDependencies: - react: ^18.0.0 || ^19.0.0 - react-dom: ^18.0.0 || ^19.0.0 + react: 18.3.1 + react-dom: 18.3.1 '@docusaurus/plugin-google-tag-manager@3.10.1': resolution: {integrity: sha512-f6fyGHiCm7kJHBtAisGQS5oNBnpnMTYQZxDXeVrnw/3zWU+LMA22pr6UHGYkBKDbN+qPC5QHG3NuOfzQLq3+Lw==} engines: {node: '>=20.0'} peerDependencies: - react: ^18.0.0 || ^19.0.0 - react-dom: ^18.0.0 || ^19.0.0 + react: 18.3.1 + react-dom: 18.3.1 '@docusaurus/plugin-sitemap@3.10.1': resolution: {integrity: sha512-C26MbmmqgdjkDq1htaZ3aD7LzEDKFWXfpyQpt0EOUThuq5nV77zDaedV20yHcVo9p+3ey9aZ4pbHA0D3QcZTzg==} engines: {node: '>=20.0'} peerDependencies: - react: ^18.0.0 || ^19.0.0 - react-dom: ^18.0.0 || ^19.0.0 + react: 18.3.1 + react-dom: 18.3.1 '@docusaurus/plugin-svgr@3.10.1': resolution: {integrity: sha512-6SFxsmjWFkVLDmBUvFK6i72QjUwqyQFe4Ovz+SUJophJjOyVG3ZZG5IQpBC/kX/Gfv1yWeU9nWauH6F6Q7QX/Q==} engines: {node: '>=20.0'} peerDependencies: - react: ^18.0.0 || ^19.0.0 - react-dom: ^18.0.0 || ^19.0.0 + react: 18.3.1 + react-dom: 18.3.1 '@docusaurus/preset-classic@3.10.1': resolution: {integrity: sha512-YO/FL8v1zmbxoTso6mjMz/RDjhaTJxb1UpFFTDdY5847LLDCeyYiYlrhyTbgN1RIN3xnkLKZ9Lj1x8hUzI4JOg==} engines: {node: '>=20.0'} peerDependencies: - react: ^18.0.0 || ^19.0.0 - react-dom: ^18.0.0 || ^19.0.0 + react: 18.3.1 + react-dom: 18.3.1 '@docusaurus/react-loadable@6.0.0': resolution: {integrity: sha512-YMMxTUQV/QFSnbgrP3tjDzLHRg7vsbMn8e9HAa8o/1iXoiomo48b7sk/kkmWEuWNDPJVlKSJRB6Y2fHqdJk+SQ==} peerDependencies: - react: '*' + react: 18.3.1 '@docusaurus/theme-classic@3.10.1': resolution: {integrity: sha512-VU1RK0qb2pab0si4r7HFK37cYco8VzqLj3u1PspVipSr/z/GPVKHO4/HXbnePqHoWDk8urjyGSeatH0NIMBM1A==} engines: {node: '>=20.0'} peerDependencies: - react: ^18.0.0 || ^19.0.0 - react-dom: ^18.0.0 || ^19.0.0 + react: 18.3.1 + react-dom: 18.3.1 '@docusaurus/theme-common@3.10.1': resolution: {integrity: sha512-0YtmIeoNo1fIw65LO8+/1dPgmDV86UmhMkow37gzjytuiCSQm9xob6PJy0L4kuQEMTLfUOGvkXvZr7GPrHquMA==} engines: {node: '>=20.0'} peerDependencies: '@docusaurus/plugin-content-docs': '*' - react: ^18.0.0 || ^19.0.0 - react-dom: ^18.0.0 || ^19.0.0 + react: 18.3.1 + react-dom: 18.3.1 '@docusaurus/theme-search-algolia@3.10.1': resolution: {integrity: sha512-OTaARARVZj2GvkJQjB+1jOIxntRaXea+G+fMsNqrZBAU1O1vJKDW22R7kECOHW27oJCLFN9HKaZeRrfAUyviug==} engines: {node: '>=20.0'} peerDependencies: - react: ^18.0.0 || ^19.0.0 - react-dom: ^18.0.0 || ^19.0.0 + react: 18.3.1 + react-dom: 18.3.1 '@docusaurus/theme-translations@3.10.1': resolution: {integrity: sha512-cLMyaKivjBVWKMJuWqyFVVgtqe8DPJNPkog0bn8W1MDVAKcPdxRFycBfC1We1RaNp7Rdk513bmtW78RR6OBxBw==} @@ -1927,8 +1922,8 @@ packages: '@docusaurus/types@3.10.1': resolution: {integrity: sha512-XYMK8k1szDCFMw2V+Xyen0g7Kee1sP3dtFnl7vkGkZOkeAJ/oPDQPL8iz4HBKOo/cwU8QeV6onVjMqtP+tFzsw==} peerDependencies: - react: ^18.0.0 || ^19.0.0 - react-dom: ^18.0.0 || ^19.0.0 + react: 18.3.1 + react-dom: 18.3.1 '@docusaurus/utils-common@3.10.1': resolution: {integrity: sha512-5mFSgEADtnFxFH7RLw02QA5MpU5JVUCj0MPeIvi/aF4Fi45tQRIuTwXoXDqJ+1VfQJuYJGz3SI63wmGz4HvXzA==} @@ -2597,8 +2592,8 @@ packages: '@floating-ui/react-dom@2.1.8': resolution: {integrity: sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A==} peerDependencies: - react: '>=16.8.0' - react-dom: '>=16.8.0' + react: 18.3.1 + react-dom: 18.3.1 '@floating-ui/utils@0.2.11': resolution: {integrity: sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==} @@ -3084,7 +3079,7 @@ packages: resolution: {integrity: sha512-f++rKLQgUVYDAtECQ6fn/is15GkEH9+nZPM3MS0RcxVqoTfawHvDlSCH7JbMhAM6uJ32v3eXLvLmLvjGu7PTQw==} peerDependencies: '@types/react': ^18.3.3 - react: '>=16' + react: 18.3.1 '@napi-rs/wasm-runtime@1.1.4': resolution: {integrity: sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==} @@ -3352,8 +3347,8 @@ packages: peerDependencies: '@types/react': ^18.3.3 '@types/react-dom': ^18.3.0 - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react: 18.3.1 + react-dom: 18.3.1 peerDependenciesMeta: '@types/react': optional: true @@ -3365,8 +3360,8 @@ packages: peerDependencies: '@types/react': ^18.3.3 '@types/react-dom': ^18.3.0 - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react: 18.3.1 + react-dom: 18.3.1 peerDependenciesMeta: '@types/react': optional: true @@ -3378,8 +3373,8 @@ packages: peerDependencies: '@types/react': ^18.3.3 '@types/react-dom': ^18.3.0 - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react: 18.3.1 + react-dom: 18.3.1 peerDependenciesMeta: '@types/react': optional: true @@ -3390,7 +3385,7 @@ packages: resolution: {integrity: sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==} peerDependencies: '@types/react': ^18.3.3 - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react: 18.3.1 peerDependenciesMeta: '@types/react': optional: true @@ -3399,7 +3394,7 @@ packages: resolution: {integrity: sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==} peerDependencies: '@types/react': ^18.3.3 - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react: 18.3.1 peerDependenciesMeta: '@types/react': optional: true @@ -3409,8 +3404,8 @@ packages: peerDependencies: '@types/react': ^18.3.3 '@types/react-dom': ^18.3.0 - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react: 18.3.1 + react-dom: 18.3.1 peerDependenciesMeta: '@types/react': optional: true @@ -3421,7 +3416,7 @@ packages: resolution: {integrity: sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==} peerDependencies: '@types/react': ^18.3.3 - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react: 18.3.1 peerDependenciesMeta: '@types/react': optional: true @@ -3431,8 +3426,8 @@ packages: peerDependencies: '@types/react': ^18.3.3 '@types/react-dom': ^18.3.0 - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react: 18.3.1 + react-dom: 18.3.1 peerDependenciesMeta: '@types/react': optional: true @@ -3444,8 +3439,8 @@ packages: peerDependencies: '@types/react': ^18.3.3 '@types/react-dom': ^18.3.0 - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react: 18.3.1 + react-dom: 18.3.1 peerDependenciesMeta: '@types/react': optional: true @@ -3456,7 +3451,7 @@ packages: resolution: {integrity: sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==} peerDependencies: '@types/react': ^18.3.3 - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react: 18.3.1 peerDependenciesMeta: '@types/react': optional: true @@ -3466,8 +3461,8 @@ packages: peerDependencies: '@types/react': ^18.3.3 '@types/react-dom': ^18.3.0 - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react: 18.3.1 + react-dom: 18.3.1 peerDependenciesMeta: '@types/react': optional: true @@ -3477,13 +3472,13 @@ packages: '@radix-ui/react-icons@1.3.2': resolution: {integrity: sha512-fyQIhGDhzfc9pK2kH6Pl9c4BDJGfMkPqkyIgYDthyNYoNg3wVhoJMMh19WS4Up/1KMPFVpNsT2q3WmXn2N1m6g==} peerDependencies: - react: ^16.x || ^17.x || ^18.x || ^19.0.0 || ^19.0.0-rc + react: 18.3.1 '@radix-ui/react-id@1.1.1': resolution: {integrity: sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==} peerDependencies: '@types/react': ^18.3.3 - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react: 18.3.1 peerDependenciesMeta: '@types/react': optional: true @@ -3493,8 +3488,8 @@ packages: peerDependencies: '@types/react': ^18.3.3 '@types/react-dom': ^18.3.0 - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react: 18.3.1 + react-dom: 18.3.1 peerDependenciesMeta: '@types/react': optional: true @@ -3506,8 +3501,8 @@ packages: peerDependencies: '@types/react': ^18.3.3 '@types/react-dom': ^18.3.0 - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react: 18.3.1 + react-dom: 18.3.1 peerDependenciesMeta: '@types/react': optional: true @@ -3519,8 +3514,8 @@ packages: peerDependencies: '@types/react': ^18.3.3 '@types/react-dom': ^18.3.0 - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react: 18.3.1 + react-dom: 18.3.1 peerDependenciesMeta: '@types/react': optional: true @@ -3532,8 +3527,8 @@ packages: peerDependencies: '@types/react': ^18.3.3 '@types/react-dom': ^18.3.0 - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react: 18.3.1 + react-dom: 18.3.1 peerDependenciesMeta: '@types/react': optional: true @@ -3545,8 +3540,8 @@ packages: peerDependencies: '@types/react': ^18.3.3 '@types/react-dom': ^18.3.0 - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react: 18.3.1 + react-dom: 18.3.1 peerDependenciesMeta: '@types/react': optional: true @@ -3558,8 +3553,8 @@ packages: peerDependencies: '@types/react': ^18.3.3 '@types/react-dom': ^18.3.0 - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react: 18.3.1 + react-dom: 18.3.1 peerDependenciesMeta: '@types/react': optional: true @@ -3571,8 +3566,8 @@ packages: peerDependencies: '@types/react': ^18.3.3 '@types/react-dom': ^18.3.0 - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react: 18.3.1 + react-dom: 18.3.1 peerDependenciesMeta: '@types/react': optional: true @@ -3584,8 +3579,8 @@ packages: peerDependencies: '@types/react': ^18.3.3 '@types/react-dom': ^18.3.0 - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react: 18.3.1 + react-dom: 18.3.1 peerDependenciesMeta: '@types/react': optional: true @@ -3597,8 +3592,8 @@ packages: peerDependencies: '@types/react': ^18.3.3 '@types/react-dom': ^18.3.0 - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react: 18.3.1 + react-dom: 18.3.1 peerDependenciesMeta: '@types/react': optional: true @@ -3610,8 +3605,8 @@ packages: peerDependencies: '@types/react': ^18.3.3 '@types/react-dom': ^18.3.0 - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react: 18.3.1 + react-dom: 18.3.1 peerDependenciesMeta: '@types/react': optional: true @@ -3623,8 +3618,8 @@ packages: peerDependencies: '@types/react': ^18.3.3 '@types/react-dom': ^18.3.0 - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react: 18.3.1 + react-dom: 18.3.1 peerDependenciesMeta: '@types/react': optional: true @@ -3635,7 +3630,7 @@ packages: resolution: {integrity: sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==} peerDependencies: '@types/react': ^18.3.3 - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react: 18.3.1 peerDependenciesMeta: '@types/react': optional: true @@ -3644,7 +3639,7 @@ packages: resolution: {integrity: sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==} peerDependencies: '@types/react': ^18.3.3 - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react: 18.3.1 peerDependenciesMeta: '@types/react': optional: true @@ -3654,8 +3649,8 @@ packages: peerDependencies: '@types/react': ^18.3.3 '@types/react-dom': ^18.3.0 - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react: 18.3.1 + react-dom: 18.3.1 peerDependenciesMeta: '@types/react': optional: true @@ -3666,7 +3661,7 @@ packages: resolution: {integrity: sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==} peerDependencies: '@types/react': ^18.3.3 - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react: 18.3.1 peerDependenciesMeta: '@types/react': optional: true @@ -3675,7 +3670,7 @@ packages: resolution: {integrity: sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==} peerDependencies: '@types/react': ^18.3.3 - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react: 18.3.1 peerDependenciesMeta: '@types/react': optional: true @@ -3684,7 +3679,7 @@ packages: resolution: {integrity: sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==} peerDependencies: '@types/react': ^18.3.3 - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react: 18.3.1 peerDependenciesMeta: '@types/react': optional: true @@ -3693,7 +3688,7 @@ packages: resolution: {integrity: sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==} peerDependencies: '@types/react': ^18.3.3 - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react: 18.3.1 peerDependenciesMeta: '@types/react': optional: true @@ -3702,7 +3697,7 @@ packages: resolution: {integrity: sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==} peerDependencies: '@types/react': ^18.3.3 - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react: 18.3.1 peerDependenciesMeta: '@types/react': optional: true @@ -3711,7 +3706,7 @@ packages: resolution: {integrity: sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==} peerDependencies: '@types/react': ^18.3.3 - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react: 18.3.1 peerDependenciesMeta: '@types/react': optional: true @@ -3720,7 +3715,7 @@ packages: resolution: {integrity: sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==} peerDependencies: '@types/react': ^18.3.3 - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react: 18.3.1 peerDependenciesMeta: '@types/react': optional: true @@ -3729,7 +3724,7 @@ packages: resolution: {integrity: sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==} peerDependencies: '@types/react': ^18.3.3 - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react: 18.3.1 peerDependenciesMeta: '@types/react': optional: true @@ -3739,8 +3734,8 @@ packages: peerDependencies: '@types/react': ^18.3.3 '@types/react-dom': ^18.3.0 - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react: 18.3.1 + react-dom: 18.3.1 peerDependenciesMeta: '@types/react': optional: true @@ -4076,8 +4071,8 @@ packages: '@slorber/react-helmet-async@1.3.0': resolution: {integrity: sha512-e9/OK8VhwUSc67diWI8Rb3I0YgI9/SBQtnhe9aEuK6MhZm7ntZZimXgwXnd8W96YTmSOb9M4d8LwhRZyhWr/1A==} peerDependencies: - react: ^16.6.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - react-dom: ^16.6.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react: 18.3.1 + react-dom: 18.3.1 '@slorber/remark-comment@1.0.0': resolution: {integrity: sha512-RCE24n7jsOj1M0UPvIQCHTe7fI0sFL4S2nwKVWwHyVr/wI/H8GosgsJGyhnsZoGFnD/P2hLf1mSbrrgSLN93NA==} @@ -4197,7 +4192,7 @@ packages: '@tanstack/react-query@5.100.9': resolution: {integrity: sha512-Oa44XkaI3kCNN6ME0KByU3xT3SEUNOMfZpHxL6+wFoTm+OeUFYHKdeYVe0aOXlRDm/f15sgLwEt2HDorIdW8+A==} peerDependencies: - react: ^18 || ^19 + react: 18.3.1 '@testing-library/dom@10.4.1': resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} @@ -4214,8 +4209,8 @@ packages: '@testing-library/dom': ^10.0.0 '@types/react': ^18.3.3 '@types/react-dom': ^18.3.0 - react: ^18.0.0 || ^19.0.0 - react-dom: ^18.0.0 || ^19.0.0 + react: 18.3.1 + react-dom: 18.3.1 peerDependenciesMeta: '@types/react': optional: true @@ -4238,7 +4233,7 @@ packages: '@tanstack/react-query': ^5.80.3 '@trpc/client': 11.17.0 '@trpc/server': 11.17.0 - react: '>=18.2.0' + react: 18.3.1 typescript: '>=5.7.2' '@trpc/server@11.17.0': @@ -5020,8 +5015,8 @@ packages: next: ^14.0.0 || ^15.0.0 || ^16.0.0 pg: ^8.0.0 prisma: ^5.0.0 || ^6.0.0 || ^7.0.0 - react: ^18.0.0 || ^19.0.0 - react-dom: ^18.0.0 || ^19.0.0 + react: 18.3.1 + react-dom: 18.3.1 solid-js: ^1.0.0 svelte: ^4.0.0 || ^5.0.0 vitest: ^2.0.0 || ^3.0.0 || ^4.0.0 @@ -5328,8 +5323,8 @@ packages: cmdk@1.1.1: resolution: {integrity: sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg==} peerDependencies: - react: ^18 || ^19 || ^19.0.0-rc - react-dom: ^18 || ^19 || ^19.0.0-rc + react: 18.3.1 + react-dom: 18.3.1 collapse-white-space@2.1.0: resolution: {integrity: sha512-loKTxY1zCOuG4j9f6EPnuyyYkf58RnhhWTvRoZEokgB+WbdXehfjFviyOVYkqzEWz1Q5kRiZdBYS5SwxbQYwzw==} @@ -7496,7 +7491,7 @@ packages: lucide-react@0.436.0: resolution: {integrity: sha512-N292bIxoqm1aObAg0MzFtvhYwgQE6qnIOWx/GLj5ONgcTPH6N0fD9bVq/GfdeC9ZORBXozt/XeEKDpiB3x3vlQ==} peerDependencies: - react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc + react: 18.3.1 lz-string@1.5.0: resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} @@ -7871,8 +7866,8 @@ packages: next-themes@0.4.6: resolution: {integrity: sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==} peerDependencies: - react: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc - react-dom: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc + react: 18.3.1 + react-dom: 18.3.1 next@16.2.6: resolution: {integrity: sha512-qOVgKJg1+At15NpeUP+eJgCHvTCgXsogweq87Ri/Ix7PkqQHg4sdaXmSFqKlgaIXE4kW0g25LE68W87UANlHtw==} @@ -7882,8 +7877,8 @@ packages: '@opentelemetry/api': ^1.1.0 '@playwright/test': ^1.51.1 babel-plugin-react-compiler: '*' - react: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0 - react-dom: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0 + react: 18.3.1 + react-dom: 18.3.1 sass: ^1.3.0 peerDependenciesMeta: '@opentelemetry/api': @@ -8824,7 +8819,7 @@ packages: prism-react-renderer@2.4.1: resolution: {integrity: sha512-ey8Ls/+Di31eqzUxC46h8MksNuGx/n0AAC8uKpwFau4RPDYLuE3EXTp8N8G2vX2N7UC/+IXeNUnlWBGGcAG+Ig==} peerDependencies: - react: '>=16.0.0' + react: 18.3.1 prismjs@1.30.0: resolution: {integrity: sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==} @@ -8935,12 +8930,12 @@ packages: react-dom@18.3.1: resolution: {integrity: sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==} peerDependencies: - react: ^18.3.1 + react: 18.3.1 react-dom@19.2.5: resolution: {integrity: sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==} peerDependencies: - react: ^19.2.5 + react: 18.3.1 react-fast-compare@3.2.2: resolution: {integrity: sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ==} @@ -8949,7 +8944,7 @@ packages: resolution: {integrity: sha512-Ovv94H+0p3sJ7B9B5QxPuCP1u8V/cHuVGyH55cSwodYDtoJwK+fqk3vjfIgSX59I2U/bU4z0nRJ9HMLpNiWEmw==} engines: {node: '>=18.0.0'} peerDependencies: - react: ^16.8.0 || ^17 || ^18 || ^19 + react: 18.3.1 react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} @@ -8961,7 +8956,7 @@ packages: resolution: {integrity: sha512-tk7o7QG9oYyELWHL8xiMQ8x4WzjCzbWNyig3uexmkLb54r8jO0yH3WCWx8UZS0c49eSA4QUmG5caiRJ8fAn58g==} engines: {node: '>=18'} peerDependencies: - react: ^18.0.0 || ^19.0.0 + react: 18.3.1 react-loadable-ssr-addon-v5-slorber@1.0.3: resolution: {integrity: sha512-GXfh9VLwB5ERaCsU6RULh7tkemeX15aNh6wuMEBtfdyMa7fFG8TXrhXlx1SoEK2Ty/l6XIkzzYIQmyaWW3JgdQ==} @@ -8975,7 +8970,7 @@ packages: engines: {node: '>=10'} peerDependencies: '@types/react': ^18.3.3 - react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react: 18.3.1 peerDependenciesMeta: '@types/react': optional: true @@ -8985,7 +8980,7 @@ packages: engines: {node: '>=10'} peerDependencies: '@types/react': ^18.3.3 - react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + react: 18.3.1 peerDependenciesMeta: '@types/react': optional: true @@ -8993,31 +8988,31 @@ packages: react-router-config@5.1.1: resolution: {integrity: sha512-DuanZjaD8mQp1ppHjgnnUnyOlqYXZVjnov/JzFhjLEwd3Z4dYjMSnqrEzzGThH47vpCOqPPwJM2FtthLeJ8Pbg==} peerDependencies: - react: '>=15' + react: 18.3.1 react-router: '>=5' react-router-dom@5.3.4: resolution: {integrity: sha512-m4EqFMHv/Ih4kpcBCONHbkT68KoAeHN4p3lAGoNryfHi0dMy0kCzEZakiKRsvg5wHZ/JLrLW8o8KomWiz/qbYQ==} peerDependencies: - react: '>=15' + react: 18.3.1 react-router@5.3.4: resolution: {integrity: sha512-Ys9K+ppnJah3QuaRiLxk+jDWOR1MekYQrlytiXxC1RyfbdsZkS5pvKAzCCr031xHixZwpnsYNT5xysdFHQaYsA==} peerDependencies: - react: '>=15' + react: 18.3.1 react-scroll@1.9.3: resolution: {integrity: sha512-xv7FXqF3k63aSLNu4/NjFvRNI0ge7DmmmsbeGarP7LZVAlJMSjUuW3dTtLxp1Afijyv0lS2qwC0GiFHvx1KBHQ==} peerDependencies: - react: ^15.5.4 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - react-dom: ^15.5.4 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react: 18.3.1 + react-dom: 18.3.1 react-style-singleton@2.2.3: resolution: {integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==} engines: {node: '>=10'} peerDependencies: '@types/react': ^18.3.3 - react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + react: 18.3.1 peerDependenciesMeta: '@types/react': optional: true @@ -9479,8 +9474,8 @@ packages: sonner@1.7.4: resolution: {integrity: sha512-DIS8z4PfJRbIyfVFDVnK9rO3eYDtse4Omcm6bt0oEr5/jtLgysmjuBl1frJ9E/EQZrFmKx2A8m/s5s9CRXIzhw==} peerDependencies: - react: ^18.0.0 || ^19.0.0 || ^19.0.0-rc - react-dom: ^18.0.0 || ^19.0.0 || ^19.0.0-rc + react: 18.3.1 + react-dom: 18.3.1 sort-css-media-queries@2.2.0: resolution: {integrity: sha512-0xtkGhWCC9MGt/EzgnvbbbKhqWjl1+/rncmhTh5qCpbYguXh6S/qwePfv/JQ8jePXXmqingylxoC49pCkSPIbA==} @@ -9640,7 +9635,7 @@ packages: peerDependencies: '@babel/core': '*' babel-plugin-macros: '*' - react: '>= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0' + react: 18.3.1 peerDependenciesMeta: '@babel/core': optional: true @@ -10159,7 +10154,7 @@ packages: engines: {node: '>=10'} peerDependencies: '@types/react': ^18.3.3 - react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + react: 18.3.1 peerDependenciesMeta: '@types/react': optional: true @@ -10169,7 +10164,7 @@ packages: engines: {node: '>=10'} peerDependencies: '@types/react': ^18.3.3 - react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + react: 18.3.1 peerDependenciesMeta: '@types/react': optional: true From 547a744d05dc0a2b62151094b3feb87075973e4d Mon Sep 17 00:00:00 2001 From: gpalmer27 Date: Wed, 3 Jun 2026 22:46:32 -0400 Subject: [PATCH 6/6] fixed dependency --- package.json | 2 + packages/ui/package.json | 3 + pnpm-lock.yaml | 257 +++++++++++++++++++-------------------- 3 files changed, 132 insertions(+), 130 deletions(-) diff --git a/package.json b/package.json index 81f85a43..fe0425d5 100644 --- a/package.json +++ b/package.json @@ -49,6 +49,8 @@ "@vitest/ui": "^4.1.5", "jsdom": "^25.0.1", "prettier": "catalog:", + "react": "catalog:react18", + "react-dom": "catalog:react18", "turbo": "^2.9.14", "typescript": "catalog:", "vitest": "catalog:" diff --git a/packages/ui/package.json b/packages/ui/package.json index c5969bec..3f3b88b3 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -45,15 +45,18 @@ "@cooper/tailwind-config": "workspace:*", "@cooper/tsconfig": "workspace:*", "@types/react": "^18.3.28", + "@types/react-dom": "^18.3.7", "eslint": "catalog:", "prettier": "catalog:", "react": "catalog:react18", + "react-dom": "catalog:react18", "tailwindcss": "^3.4.19", "typescript": "catalog:", "zod": "catalog:" }, "peerDependencies": { "react": "catalog:react18", + "react-dom": "catalog:react18", "zod": "catalog:" }, "prettier": "@cooper/prettier-config" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1a38a835..0bddf74b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -49,7 +49,7 @@ importers: version: 6.9.1 '@testing-library/react': specifier: ^16.3.2 - version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.5(react@18.3.1))(react@18.3.1) + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@turbo/gen': specifier: ^2.9.14 version: 2.9.16(@types/node@25.9.1) @@ -71,6 +71,12 @@ importers: prettier: specifier: 'catalog:' version: 3.3.3 + react: + specifier: 18.3.1 + version: 18.3.1 + react-dom: + specifier: 18.3.1 + version: 18.3.1(react@18.3.1) turbo: specifier: ^2.9.14 version: 2.9.14 @@ -443,49 +449,49 @@ importers: version: 3.10.0(react-hook-form@7.75.0(react@18.3.1)) '@radix-ui/react-checkbox': specifier: ^1.3.3 - version: 1.3.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.5(react@18.3.1))(react@18.3.1) + version: 1.3.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-dialog': specifier: ^1.1.15 - version: 1.1.15(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.5(react@18.3.1))(react@18.3.1) + version: 1.1.15(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-dropdown-menu': specifier: ^2.1.16 - version: 2.1.16(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.5(react@18.3.1))(react@18.3.1) + version: 2.1.16(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-icons': specifier: ^1.3.2 version: 1.3.2(react@18.3.1) '@radix-ui/react-label': specifier: ^2.1.8 - version: 2.1.8(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.5(react@18.3.1))(react@18.3.1) + version: 2.1.8(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-popover': specifier: ^1.1.15 - version: 1.1.15(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.5(react@18.3.1))(react@18.3.1) + version: 1.1.15(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-radio-group': specifier: ^1.3.8 - version: 1.3.8(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.5(react@18.3.1))(react@18.3.1) + version: 1.3.8(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-select': specifier: ^2.2.6 - version: 2.2.6(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.5(react@18.3.1))(react@18.3.1) + version: 2.2.6(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-slot': specifier: ^1.2.4 version: 1.2.4(@types/react@18.3.28)(react@18.3.1) '@radix-ui/react-toast': specifier: ^1.2.15 - version: 1.2.15(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.5(react@18.3.1))(react@18.3.1) + version: 1.2.15(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) class-variance-authority: specifier: ^0.7.1 version: 0.7.1 cmdk: specifier: ^1.1.1 - version: 1.1.1(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.5(react@18.3.1))(react@18.3.1) + version: 1.1.1(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) next-themes: specifier: ^0.4.6 - version: 0.4.6(react-dom@19.2.5(react@18.3.1))(react@18.3.1) + version: 0.4.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1) react-hook-form: specifier: ^7.75.0 version: 7.75.0(react@18.3.1) sonner: specifier: ^1.7.4 - version: 1.7.4(react-dom@19.2.5(react@18.3.1))(react@18.3.1) + version: 1.7.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) tailwind-merge: specifier: ^2.6.1 version: 2.6.1 @@ -508,6 +514,9 @@ importers: '@types/react': specifier: ^18.3.3 version: 18.3.28 + '@types/react-dom': + specifier: ^18.3.0 + version: 18.3.7(@types/react@18.3.28) eslint: specifier: 'catalog:' version: 9.7.0 @@ -517,6 +526,9 @@ importers: react: specifier: 18.3.1 version: 18.3.1 + react-dom: + specifier: 18.3.1 + version: 18.3.1(react@18.3.1) tailwindcss: specifier: ^3.4.19 version: 3.4.19(tsx@4.21.0)(yaml@2.8.3) @@ -8932,11 +8944,6 @@ packages: peerDependencies: react: 18.3.1 - react-dom@19.2.5: - resolution: {integrity: sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==} - peerDependencies: - react: 18.3.1 - react-fast-compare@3.2.2: resolution: {integrity: sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ==} @@ -9276,9 +9283,6 @@ packages: scheduler@0.23.2: resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==} - scheduler@0.27.0: - resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} - schema-dts@1.1.5: resolution: {integrity: sha512-RJr9EaCmsLzBX2NDiO5Z3ux2BVosNZN5jo0gWgsyKvxKIUL5R3swNvoorulAeL9kLB0iTSX7V6aokhla2m7xbg==} @@ -13182,11 +13186,11 @@ snapshots: '@floating-ui/core': 1.7.5 '@floating-ui/utils': 0.2.11 - '@floating-ui/react-dom@2.1.8(react-dom@19.2.5(react@18.3.1))(react@18.3.1)': + '@floating-ui/react-dom@2.1.8(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@floating-ui/dom': 1.7.6 react: 18.3.1 - react-dom: 19.2.5(react@18.3.1) + react-dom: 18.3.1(react@18.3.1) '@floating-ui/utils@0.2.11': {} @@ -13932,39 +13936,39 @@ snapshots: '@radix-ui/primitive@1.1.3': {} - '@radix-ui/react-arrow@1.1.7(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.5(react@18.3.1))(react@18.3.1)': + '@radix-ui/react-arrow@1.1.7(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.5(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) react: 18.3.1 - react-dom: 19.2.5(react@18.3.1) + react-dom: 18.3.1(react@18.3.1) optionalDependencies: '@types/react': 18.3.28 '@types/react-dom': 18.3.7(@types/react@18.3.28) - '@radix-ui/react-checkbox@1.3.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.5(react@18.3.1))(react@18.3.1)': + '@radix-ui/react-checkbox@1.3.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@radix-ui/primitive': 1.1.3 '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.28)(react@18.3.1) '@radix-ui/react-context': 1.1.2(@types/react@18.3.28)(react@18.3.1) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.5(react@18.3.1))(react@18.3.1) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.5(react@18.3.1))(react@18.3.1) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.28)(react@18.3.1) '@radix-ui/react-use-previous': 1.1.1(@types/react@18.3.28)(react@18.3.1) '@radix-ui/react-use-size': 1.1.1(@types/react@18.3.28)(react@18.3.1) react: 18.3.1 - react-dom: 19.2.5(react@18.3.1) + react-dom: 18.3.1(react@18.3.1) optionalDependencies: '@types/react': 18.3.28 '@types/react-dom': 18.3.7(@types/react@18.3.28) - '@radix-ui/react-collection@1.1.7(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.5(react@18.3.1))(react@18.3.1)': + '@radix-ui/react-collection@1.1.7(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.28)(react@18.3.1) '@radix-ui/react-context': 1.1.2(@types/react@18.3.28)(react@18.3.1) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.5(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-slot': 1.2.3(@types/react@18.3.28)(react@18.3.1) react: 18.3.1 - react-dom: 19.2.5(react@18.3.1) + react-dom: 18.3.1(react@18.3.1) optionalDependencies: '@types/react': 18.3.28 '@types/react-dom': 18.3.7(@types/react@18.3.28) @@ -13981,23 +13985,23 @@ snapshots: optionalDependencies: '@types/react': 18.3.28 - '@radix-ui/react-dialog@1.1.15(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.5(react@18.3.1))(react@18.3.1)': + '@radix-ui/react-dialog@1.1.15(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@radix-ui/primitive': 1.1.3 '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.28)(react@18.3.1) '@radix-ui/react-context': 1.1.2(@types/react@18.3.28)(react@18.3.1) - '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.5(react@18.3.1))(react@18.3.1) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-focus-guards': 1.1.3(@types/react@18.3.28)(react@18.3.1) - '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.5(react@18.3.1))(react@18.3.1) + '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-id': 1.1.1(@types/react@18.3.28)(react@18.3.1) - '@radix-ui/react-portal': 1.1.9(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.5(react@18.3.1))(react@18.3.1) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.5(react@18.3.1))(react@18.3.1) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.5(react@18.3.1))(react@18.3.1) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-slot': 1.2.3(@types/react@18.3.28)(react@18.3.1) '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.28)(react@18.3.1) aria-hidden: 1.2.6 react: 18.3.1 - react-dom: 19.2.5(react@18.3.1) + react-dom: 18.3.1(react@18.3.1) react-remove-scroll: 2.7.2(@types/react@18.3.28)(react@18.3.1) optionalDependencies: '@types/react': 18.3.28 @@ -14009,30 +14013,30 @@ snapshots: optionalDependencies: '@types/react': 18.3.28 - '@radix-ui/react-dismissable-layer@1.1.11(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.5(react@18.3.1))(react@18.3.1)': + '@radix-ui/react-dismissable-layer@1.1.11(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@radix-ui/primitive': 1.1.3 '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.28)(react@18.3.1) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.5(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.28)(react@18.3.1) '@radix-ui/react-use-escape-keydown': 1.1.1(@types/react@18.3.28)(react@18.3.1) react: 18.3.1 - react-dom: 19.2.5(react@18.3.1) + react-dom: 18.3.1(react@18.3.1) optionalDependencies: '@types/react': 18.3.28 '@types/react-dom': 18.3.7(@types/react@18.3.28) - '@radix-ui/react-dropdown-menu@2.1.16(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.5(react@18.3.1))(react@18.3.1)': + '@radix-ui/react-dropdown-menu@2.1.16(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@radix-ui/primitive': 1.1.3 '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.28)(react@18.3.1) '@radix-ui/react-context': 1.1.2(@types/react@18.3.28)(react@18.3.1) '@radix-ui/react-id': 1.1.1(@types/react@18.3.28)(react@18.3.1) - '@radix-ui/react-menu': 2.1.16(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.5(react@18.3.1))(react@18.3.1) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.5(react@18.3.1))(react@18.3.1) + '@radix-ui/react-menu': 2.1.16(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.28)(react@18.3.1) react: 18.3.1 - react-dom: 19.2.5(react@18.3.1) + react-dom: 18.3.1(react@18.3.1) optionalDependencies: '@types/react': 18.3.28 '@types/react-dom': 18.3.7(@types/react@18.3.28) @@ -14043,13 +14047,13 @@ snapshots: optionalDependencies: '@types/react': 18.3.28 - '@radix-ui/react-focus-scope@1.1.7(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.5(react@18.3.1))(react@18.3.1)': + '@radix-ui/react-focus-scope@1.1.7(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.28)(react@18.3.1) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.5(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.28)(react@18.3.1) react: 18.3.1 - react-dom: 19.2.5(react@18.3.1) + react-dom: 18.3.1(react@18.3.1) optionalDependencies: '@types/react': 18.3.28 '@types/react-dom': 18.3.7(@types/react@18.3.28) @@ -14065,179 +14069,179 @@ snapshots: optionalDependencies: '@types/react': 18.3.28 - '@radix-ui/react-label@2.1.8(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.5(react@18.3.1))(react@18.3.1)': + '@radix-ui/react-label@2.1.8(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: - '@radix-ui/react-primitive': 2.1.4(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.5(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.1.4(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) react: 18.3.1 - react-dom: 19.2.5(react@18.3.1) + react-dom: 18.3.1(react@18.3.1) optionalDependencies: '@types/react': 18.3.28 '@types/react-dom': 18.3.7(@types/react@18.3.28) - '@radix-ui/react-menu@2.1.16(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.5(react@18.3.1))(react@18.3.1)': + '@radix-ui/react-menu@2.1.16(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-collection': 1.1.7(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.5(react@18.3.1))(react@18.3.1) + '@radix-ui/react-collection': 1.1.7(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.28)(react@18.3.1) '@radix-ui/react-context': 1.1.2(@types/react@18.3.28)(react@18.3.1) '@radix-ui/react-direction': 1.1.1(@types/react@18.3.28)(react@18.3.1) - '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.5(react@18.3.1))(react@18.3.1) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-focus-guards': 1.1.3(@types/react@18.3.28)(react@18.3.1) - '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.5(react@18.3.1))(react@18.3.1) + '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-id': 1.1.1(@types/react@18.3.28)(react@18.3.1) - '@radix-ui/react-popper': 1.2.8(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.5(react@18.3.1))(react@18.3.1) - '@radix-ui/react-portal': 1.1.9(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.5(react@18.3.1))(react@18.3.1) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.5(react@18.3.1))(react@18.3.1) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.5(react@18.3.1))(react@18.3.1) - '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.5(react@18.3.1))(react@18.3.1) + '@radix-ui/react-popper': 1.2.8(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-slot': 1.2.3(@types/react@18.3.28)(react@18.3.1) '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.28)(react@18.3.1) aria-hidden: 1.2.6 react: 18.3.1 - react-dom: 19.2.5(react@18.3.1) + react-dom: 18.3.1(react@18.3.1) react-remove-scroll: 2.7.2(@types/react@18.3.28)(react@18.3.1) optionalDependencies: '@types/react': 18.3.28 '@types/react-dom': 18.3.7(@types/react@18.3.28) - '@radix-ui/react-popover@1.1.15(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.5(react@18.3.1))(react@18.3.1)': + '@radix-ui/react-popover@1.1.15(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@radix-ui/primitive': 1.1.3 '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.28)(react@18.3.1) '@radix-ui/react-context': 1.1.2(@types/react@18.3.28)(react@18.3.1) - '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.5(react@18.3.1))(react@18.3.1) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-focus-guards': 1.1.3(@types/react@18.3.28)(react@18.3.1) - '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.5(react@18.3.1))(react@18.3.1) + '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-id': 1.1.1(@types/react@18.3.28)(react@18.3.1) - '@radix-ui/react-popper': 1.2.8(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.5(react@18.3.1))(react@18.3.1) - '@radix-ui/react-portal': 1.1.9(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.5(react@18.3.1))(react@18.3.1) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.5(react@18.3.1))(react@18.3.1) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.5(react@18.3.1))(react@18.3.1) + '@radix-ui/react-popper': 1.2.8(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-slot': 1.2.3(@types/react@18.3.28)(react@18.3.1) '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.28)(react@18.3.1) aria-hidden: 1.2.6 react: 18.3.1 - react-dom: 19.2.5(react@18.3.1) + react-dom: 18.3.1(react@18.3.1) react-remove-scroll: 2.7.2(@types/react@18.3.28)(react@18.3.1) optionalDependencies: '@types/react': 18.3.28 '@types/react-dom': 18.3.7(@types/react@18.3.28) - '@radix-ui/react-popper@1.2.8(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.5(react@18.3.1))(react@18.3.1)': + '@radix-ui/react-popper@1.2.8(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: - '@floating-ui/react-dom': 2.1.8(react-dom@19.2.5(react@18.3.1))(react@18.3.1) - '@radix-ui/react-arrow': 1.1.7(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.5(react@18.3.1))(react@18.3.1) + '@floating-ui/react-dom': 2.1.8(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-arrow': 1.1.7(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.28)(react@18.3.1) '@radix-ui/react-context': 1.1.2(@types/react@18.3.28)(react@18.3.1) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.5(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.28)(react@18.3.1) '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.28)(react@18.3.1) '@radix-ui/react-use-rect': 1.1.1(@types/react@18.3.28)(react@18.3.1) '@radix-ui/react-use-size': 1.1.1(@types/react@18.3.28)(react@18.3.1) '@radix-ui/rect': 1.1.1 react: 18.3.1 - react-dom: 19.2.5(react@18.3.1) + react-dom: 18.3.1(react@18.3.1) optionalDependencies: '@types/react': 18.3.28 '@types/react-dom': 18.3.7(@types/react@18.3.28) - '@radix-ui/react-portal@1.1.9(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.5(react@18.3.1))(react@18.3.1)': + '@radix-ui/react-portal@1.1.9(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.5(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.28)(react@18.3.1) react: 18.3.1 - react-dom: 19.2.5(react@18.3.1) + react-dom: 18.3.1(react@18.3.1) optionalDependencies: '@types/react': 18.3.28 '@types/react-dom': 18.3.7(@types/react@18.3.28) - '@radix-ui/react-presence@1.1.5(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.5(react@18.3.1))(react@18.3.1)': + '@radix-ui/react-presence@1.1.5(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.28)(react@18.3.1) '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.28)(react@18.3.1) react: 18.3.1 - react-dom: 19.2.5(react@18.3.1) + react-dom: 18.3.1(react@18.3.1) optionalDependencies: '@types/react': 18.3.28 '@types/react-dom': 18.3.7(@types/react@18.3.28) - '@radix-ui/react-primitive@2.1.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.5(react@18.3.1))(react@18.3.1)': + '@radix-ui/react-primitive@2.1.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@radix-ui/react-slot': 1.2.3(@types/react@18.3.28)(react@18.3.1) react: 18.3.1 - react-dom: 19.2.5(react@18.3.1) + react-dom: 18.3.1(react@18.3.1) optionalDependencies: '@types/react': 18.3.28 '@types/react-dom': 18.3.7(@types/react@18.3.28) - '@radix-ui/react-primitive@2.1.4(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.5(react@18.3.1))(react@18.3.1)': + '@radix-ui/react-primitive@2.1.4(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@radix-ui/react-slot': 1.2.4(@types/react@18.3.28)(react@18.3.1) react: 18.3.1 - react-dom: 19.2.5(react@18.3.1) + react-dom: 18.3.1(react@18.3.1) optionalDependencies: '@types/react': 18.3.28 '@types/react-dom': 18.3.7(@types/react@18.3.28) - '@radix-ui/react-radio-group@1.3.8(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.5(react@18.3.1))(react@18.3.1)': + '@radix-ui/react-radio-group@1.3.8(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@radix-ui/primitive': 1.1.3 '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.28)(react@18.3.1) '@radix-ui/react-context': 1.1.2(@types/react@18.3.28)(react@18.3.1) '@radix-ui/react-direction': 1.1.1(@types/react@18.3.28)(react@18.3.1) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.5(react@18.3.1))(react@18.3.1) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.5(react@18.3.1))(react@18.3.1) - '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.5(react@18.3.1))(react@18.3.1) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.28)(react@18.3.1) '@radix-ui/react-use-previous': 1.1.1(@types/react@18.3.28)(react@18.3.1) '@radix-ui/react-use-size': 1.1.1(@types/react@18.3.28)(react@18.3.1) react: 18.3.1 - react-dom: 19.2.5(react@18.3.1) + react-dom: 18.3.1(react@18.3.1) optionalDependencies: '@types/react': 18.3.28 '@types/react-dom': 18.3.7(@types/react@18.3.28) - '@radix-ui/react-roving-focus@1.1.11(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.5(react@18.3.1))(react@18.3.1)': + '@radix-ui/react-roving-focus@1.1.11(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-collection': 1.1.7(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.5(react@18.3.1))(react@18.3.1) + '@radix-ui/react-collection': 1.1.7(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.28)(react@18.3.1) '@radix-ui/react-context': 1.1.2(@types/react@18.3.28)(react@18.3.1) '@radix-ui/react-direction': 1.1.1(@types/react@18.3.28)(react@18.3.1) '@radix-ui/react-id': 1.1.1(@types/react@18.3.28)(react@18.3.1) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.5(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.28)(react@18.3.1) '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.28)(react@18.3.1) react: 18.3.1 - react-dom: 19.2.5(react@18.3.1) + react-dom: 18.3.1(react@18.3.1) optionalDependencies: '@types/react': 18.3.28 '@types/react-dom': 18.3.7(@types/react@18.3.28) - '@radix-ui/react-select@2.2.6(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.5(react@18.3.1))(react@18.3.1)': + '@radix-ui/react-select@2.2.6(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@radix-ui/number': 1.1.1 '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-collection': 1.1.7(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.5(react@18.3.1))(react@18.3.1) + '@radix-ui/react-collection': 1.1.7(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.28)(react@18.3.1) '@radix-ui/react-context': 1.1.2(@types/react@18.3.28)(react@18.3.1) '@radix-ui/react-direction': 1.1.1(@types/react@18.3.28)(react@18.3.1) - '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.5(react@18.3.1))(react@18.3.1) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-focus-guards': 1.1.3(@types/react@18.3.28)(react@18.3.1) - '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.5(react@18.3.1))(react@18.3.1) + '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-id': 1.1.1(@types/react@18.3.28)(react@18.3.1) - '@radix-ui/react-popper': 1.2.8(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.5(react@18.3.1))(react@18.3.1) - '@radix-ui/react-portal': 1.1.9(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.5(react@18.3.1))(react@18.3.1) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.5(react@18.3.1))(react@18.3.1) + '@radix-ui/react-popper': 1.2.8(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-slot': 1.2.3(@types/react@18.3.28)(react@18.3.1) '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.28)(react@18.3.1) '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.28)(react@18.3.1) '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.28)(react@18.3.1) '@radix-ui/react-use-previous': 1.1.1(@types/react@18.3.28)(react@18.3.1) - '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.5(react@18.3.1))(react@18.3.1) + '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) aria-hidden: 1.2.6 react: 18.3.1 - react-dom: 19.2.5(react@18.3.1) + react-dom: 18.3.1(react@18.3.1) react-remove-scroll: 2.7.2(@types/react@18.3.28)(react@18.3.1) optionalDependencies: '@types/react': 18.3.28 @@ -14257,22 +14261,22 @@ snapshots: optionalDependencies: '@types/react': 18.3.28 - '@radix-ui/react-toast@1.2.15(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.5(react@18.3.1))(react@18.3.1)': + '@radix-ui/react-toast@1.2.15(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-collection': 1.1.7(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.5(react@18.3.1))(react@18.3.1) + '@radix-ui/react-collection': 1.1.7(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.28)(react@18.3.1) '@radix-ui/react-context': 1.1.2(@types/react@18.3.28)(react@18.3.1) - '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.5(react@18.3.1))(react@18.3.1) - '@radix-ui/react-portal': 1.1.9(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.5(react@18.3.1))(react@18.3.1) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.5(react@18.3.1))(react@18.3.1) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.5(react@18.3.1))(react@18.3.1) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.28)(react@18.3.1) '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.28)(react@18.3.1) '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.28)(react@18.3.1) - '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.5(react@18.3.1))(react@18.3.1) + '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) react: 18.3.1 - react-dom: 19.2.5(react@18.3.1) + react-dom: 18.3.1(react@18.3.1) optionalDependencies: '@types/react': 18.3.28 '@types/react-dom': 18.3.7(@types/react@18.3.28) @@ -14331,11 +14335,11 @@ snapshots: optionalDependencies: '@types/react': 18.3.28 - '@radix-ui/react-visually-hidden@1.2.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.5(react@18.3.1))(react@18.3.1)': + '@radix-ui/react-visually-hidden@1.2.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.5(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) react: 18.3.1 - react-dom: 19.2.5(react@18.3.1) + react-dom: 18.3.1(react@18.3.1) optionalDependencies: '@types/react': 18.3.28 '@types/react-dom': 18.3.7(@types/react@18.3.28) @@ -14714,12 +14718,12 @@ snapshots: picocolors: 1.1.1 redent: 3.0.0 - '@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.5(react@18.3.1))(react@18.3.1)': + '@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@babel/runtime': 7.29.7 '@testing-library/dom': 10.4.1 react: 18.3.1 - react-dom: 19.2.5(react@18.3.1) + react-dom: 18.3.1(react@18.3.1) optionalDependencies: '@types/react': 18.3.28 '@types/react-dom': 18.3.7(@types/react@18.3.28) @@ -15972,14 +15976,14 @@ snapshots: cluster-key-slot@1.1.2: {} - cmdk@1.1.1(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.5(react@18.3.1))(react@18.3.1): + cmdk@1.1.1(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.28)(react@18.3.1) - '@radix-ui/react-dialog': 1.1.15(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.5(react@18.3.1))(react@18.3.1) + '@radix-ui/react-dialog': 1.1.15(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-id': 1.1.1(@types/react@18.3.28)(react@18.3.1) - '@radix-ui/react-primitive': 2.1.4(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.5(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.1.4(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) react: 18.3.1 - react-dom: 19.2.5(react@18.3.1) + react-dom: 18.3.1(react@18.3.1) transitivePeerDependencies: - '@types/react' - '@types/react-dom' @@ -18985,10 +18989,10 @@ snapshots: netmask@2.1.1: {} - next-themes@0.4.6(react-dom@19.2.5(react@18.3.1))(react@18.3.1): + next-themes@0.4.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: react: 18.3.1 - react-dom: 19.2.5(react@18.3.1) + react-dom: 18.3.1(react@18.3.1) next@16.2.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: @@ -20188,11 +20192,6 @@ snapshots: react: 18.3.1 scheduler: 0.23.2 - react-dom@19.2.5(react@18.3.1): - dependencies: - react: 18.3.1 - scheduler: 0.27.0 - react-fast-compare@3.2.2: {} react-hook-form@7.75.0(react@18.3.1): @@ -20659,8 +20658,6 @@ snapshots: dependencies: loose-envify: 1.4.0 - scheduler@0.27.0: {} - schema-dts@1.1.5: {} schema-utils@3.3.0: @@ -20945,10 +20942,10 @@ snapshots: ip-address: 10.2.0 smart-buffer: 4.2.0 - sonner@1.7.4(react-dom@19.2.5(react@18.3.1))(react@18.3.1): + sonner@1.7.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: react: 18.3.1 - react-dom: 19.2.5(react@18.3.1) + react-dom: 18.3.1(react@18.3.1) sort-css-media-queries@2.2.0: {}