diff --git a/.gitignore b/.gitignore index 7be53cb..f5e550b 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ # testing /coverage +/.vitest # next.js /.next/ diff --git a/README.md b/README.md index ad6fb96..b36e522 100644 --- a/README.md +++ b/README.md @@ -210,6 +210,7 @@ After the production deploy succeeds: | `pnpm lint` | Run ESLint. | | `pnpm typecheck` | Run TypeScript without emitting files. | | `pnpm test` | Run the Vitest test suite once. | +| `pnpm test:watch` | Run Vitest in watch mode while developing. | | `pnpm db:generate` | Generate SQL migrations from the Drizzle schemas. | | `pnpm db:migrate` | Apply pending migrations to the running local Netlify database. | | `pnpm db:status` | Show local database connection and migration status. | @@ -225,6 +226,30 @@ pnpm test pnpm build ``` +## Testing + +The test suite is pinned to the Vitest 5 beta requested by the project and is +split across two environments: + +- React integration tests run in jsdom with React Testing Library, DOM Testing + Library, `user-event`, and `jest-dom`. They interact through accessible labels, + roles, and visible status messages, with only the network or Server Action + boundary mocked. +- Server and persistence integration tests run in Node and start an isolated + Netlify Database emulator. They apply every committed migration before testing + real Drizzle inserts, transactions, authorization decisions, moderation, + cancellation, and public-query visibility. + +Run the complete suite once with `pnpm test`, or use `pnpm test:watch` for fast +feedback while editing. The database-backed files start and stop their own +database, so `pnpm dev` does not need to be running. + +The local Netlify Database emulator uses PGlite and does not reproduce +cross-connection PostgreSQL row-lock blocking. Keep the cancellation concurrency +guard (`FOR UPDATE` on the parent event) covered in deploy-preview smoke testing, +and use a real Postgres-compatible test database before changing that locking +path. + ## Security notes - Keep `BETTER_AUTH_SECRET` and `NETLIFY_DB_URL` out of Git, logs, screenshots, client components, and `NEXT_PUBLIC_*` variables. Rotate any value that is exposed. @@ -247,3 +272,6 @@ pnpm build - [Better Auth dynamic base URL](https://better-auth.com/docs/guides/dynamic-base-url) - [Better Auth CLI and `create-admin`](https://better-auth.com/docs/concepts/cli) - [Better Auth Admin plugin](https://better-auth.com/docs/plugins/admin) +- [Vitest guide](https://main.vitest.dev/guide/) +- [React Testing Library introduction](https://testing-library.com/docs/react-testing-library/intro/) +- [DOM Testing Library installation](https://testing-library.com/docs/dom-testing-library/install/) diff --git a/app/account/cancel-event-form.integration.test.tsx b/app/account/cancel-event-form.integration.test.tsx new file mode 100644 index 0000000..c89beda --- /dev/null +++ b/app/account/cancel-event-form.integration.test.tsx @@ -0,0 +1,188 @@ +// @vitest-environment jsdom + +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { initialCancellationFormState } from "@/lib/events/state"; +import { CancelEventForm } from "./cancel-event-form"; + +const { cancelEventMock } = vi.hoisted(() => ({ + cancelEventMock: vi.fn(), +})); + +vi.mock("@/lib/events/actions", () => ({ + cancelEvent: cancelEventMock, +})); + +const recurringProps = { + defaultOccurrenceDate: "2026-09-09", + eventId: "10000000-0000-4000-8000-000000000001", + eventTitle: "Sacramento TypeScript Meetup", + isRecurring: true, + maxOccurrenceDate: "2026-12-30", + minOccurrenceDate: "2026-09-01", +} as const; + +function submittedFields(callIndex = 0) { + const [eventId, previousState, formData] = cancelEventMock.mock.calls[callIndex] as [ + string, + typeof initialCancellationFormState, + FormData, + ]; + + return { + eventId, + fields: Object.fromEntries(formData.entries()), + previousState, + }; +} + +describe("CancelEventForm", () => { + beforeEach(() => { + cancelEventMock.mockReset(); + }); + + it("submits a confirmed one-off cancellation with the selected occurrence", async () => { + const user = userEvent.setup(); + const confirm = vi.spyOn(window, "confirm").mockReturnValue(true); + cancelEventMock.mockResolvedValue({ + message: "Occurrence canceled. The rest of the series is unchanged.", + status: "success", + }); + + render(); + + const dateInput = screen.getByLabelText( + "Occurrence date", + ) as HTMLInputElement; + expect(dateInput).toHaveValue("2026-09-09"); + expect(dateInput).toHaveAttribute("min", "2026-09-01"); + expect(dateInput).toHaveAttribute("max", "2026-12-30"); + + await user.clear(dateInput); + await user.type(dateInput, "2026-09-16"); + await user.click( + screen.getByRole("button", { name: "Cancel this occurrence" }), + ); + + expect(confirm).toHaveBeenCalledWith( + "Cancel the September 16, 2026 occurrence of “Sacramento TypeScript Meetup”? The rest of the series will remain scheduled.", + ); + await waitFor(() => expect(cancelEventMock).toHaveBeenCalledTimes(1)); + expect(submittedFields()).toEqual({ + eventId: recurringProps.eventId, + fields: { + occurrenceDate: "2026-09-16", + scope: "occurrence", + }, + previousState: initialCancellationFormState, + }); + expect( + await screen.findByText( + "Occurrence canceled. The rest of the series is unchanged.", + ), + ).toHaveAttribute("role", "status"); + }); + + it("does not cross the server boundary when one-off confirmation is declined", async () => { + const user = userEvent.setup(); + const confirm = vi.spyOn(window, "confirm").mockReturnValue(false); + + render(); + await user.click( + screen.getByRole("button", { name: "Cancel this occurrence" }), + ); + + expect(confirm).toHaveBeenCalledOnce(); + expect(cancelEventMock).not.toHaveBeenCalled(); + }); + + it("keeps the selected occurrence visible when the server rejects cancellation", async () => { + const user = userEvent.setup(); + vi.spyOn(window, "confirm").mockReturnValue(true); + cancelEventMock.mockResolvedValue({ + message: "That event occurrence is already canceled.", + status: "error", + }); + + render(); + const dateInput = screen.getByLabelText("Occurrence date"); + await user.clear(dateInput); + await user.type(dateInput, "2026-09-16"); + await user.click( + screen.getByRole("button", { name: "Cancel this occurrence" }), + ); + + expect( + await screen.findByRole("alert"), + ).toHaveTextContent("That event occurrence is already canceled."); + expect(dateInput).toHaveValue("2026-09-16"); + }); + + it("uses a distinct confirmed action for canceling the entire series", async () => { + const user = userEvent.setup(); + const confirm = vi.spyOn(window, "confirm").mockReturnValue(true); + cancelEventMock.mockResolvedValue({ + message: "Event canceled. It has been removed from the calendar.", + status: "success", + }); + + render(); + await user.click( + screen.getByRole("button", { name: "Cancel entire series" }), + ); + + expect(confirm).toHaveBeenCalledWith( + "Cancel “Sacramento TypeScript Meetup”? This removes the entire series from the calendar, including every future occurrence.", + ); + await waitFor(() => expect(cancelEventMock).toHaveBeenCalledTimes(1)); + expect(submittedFields()).toEqual({ + eventId: recurringProps.eventId, + fields: { scope: "event" }, + previousState: initialCancellationFormState, + }); + expect(screen.getByText("Event canceled. It has been removed from the calendar.")).toBeVisible(); + }); + + it("only offers whole-event cancellation for a non-repeating event", async () => { + const user = userEvent.setup(); + const confirm = vi.spyOn(window, "confirm").mockReturnValue(false); + + render( + , + ); + + expect( + screen.queryByLabelText("Occurrence date"), + ).not.toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: "Cancel this occurrence" }), + ).not.toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: "Cancel event" })); + expect(confirm).toHaveBeenCalledWith( + "Cancel “Sacramento TypeScript Meetup”? This removes the event from the calendar.", + ); + expect(cancelEventMock).not.toHaveBeenCalled(); + }); + + it("keeps series cancellation available when no future occurrence remains", () => { + render( + , + ); + + expect( + screen.getByText( + "This series has no upcoming occurrences available to cancel.", + ), + ).toBeVisible(); + expect( + screen.getByRole("button", { name: "Cancel entire series" }), + ).toBeEnabled(); + }); +}); diff --git a/app/account/sign-out-button.integration.test.tsx b/app/account/sign-out-button.integration.test.tsx new file mode 100644 index 0000000..9a83721 --- /dev/null +++ b/app/account/sign-out-button.integration.test.tsx @@ -0,0 +1,75 @@ +// @vitest-environment jsdom + +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { SignOutButton } from "./sign-out-button"; + +const signOutMocks = vi.hoisted(() => ({ + refresh: vi.fn(), + replace: vi.fn(), + signOut: vi.fn(), +})); + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ + refresh: signOutMocks.refresh, + replace: signOutMocks.replace, + }), +})); + +vi.mock("@/lib/auth-client", () => ({ + authClient: { signOut: signOutMocks.signOut }, +})); + +describe("SignOutButton", () => { + beforeEach(() => { + for (const mock of Object.values(signOutMocks)) { + mock.mockReset(); + } + }); + + it("signs out and returns to the local auth route", async () => { + const user = userEvent.setup(); + signOutMocks.signOut.mockResolvedValue({ error: null }); + + render(); + await user.click(screen.getByRole("button", { name: "Sign out" })); + + await waitFor(() => expect(signOutMocks.signOut).toHaveBeenCalledWith()); + expect(signOutMocks.replace).toHaveBeenCalledWith("/auth"); + expect(signOutMocks.refresh).toHaveBeenCalledOnce(); + }); + + it("surfaces an account-service error and stays on the account page", async () => { + const user = userEvent.setup(); + signOutMocks.signOut.mockResolvedValue({ + error: { message: "The session could not be revoked." }, + }); + + render(); + await user.click(screen.getByRole("button", { name: "Sign out" })); + + expect( + await screen.findByText("The session could not be revoked."), + ).toBeVisible(); + expect(signOutMocks.replace).not.toHaveBeenCalled(); + expect(signOutMocks.refresh).not.toHaveBeenCalled(); + }); + + it("recovers the button after a network failure", async () => { + const user = userEvent.setup(); + signOutMocks.signOut.mockRejectedValue(new Error("offline")); + + render(); + await user.click(screen.getByRole("button", { name: "Sign out" })); + + expect( + await screen.findByText( + "We could not reach the account service. Check your connection and try again.", + ), + ).toBeVisible(); + expect(screen.getByRole("button", { name: "Sign out" })).toBeEnabled(); + expect(signOutMocks.replace).not.toHaveBeenCalled(); + }); +}); diff --git a/app/admin/events/moderation-form.integration.test.tsx b/app/admin/events/moderation-form.integration.test.tsx new file mode 100644 index 0000000..41202d1 --- /dev/null +++ b/app/admin/events/moderation-form.integration.test.tsx @@ -0,0 +1,122 @@ +// @vitest-environment jsdom + +import { within } from "@testing-library/dom"; +import { act, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { ModerationFormState } from "@/lib/events/state"; +import { ModerationForm } from "./moderation-form"; + +const serverActions = vi.hoisted(() => ({ + moderateEvent: vi.fn(), +})); + +vi.mock("@/lib/events/actions", () => ({ + moderateEvent: serverActions.moderateEvent, +})); + +const EVENT_ID = "4af57f4b-f482-4e8a-922b-0c965329ece4"; + +function getSubmittedFormData(callIndex = 0) { + const formData = serverActions.moderateEvent.mock.calls[callIndex]?.[2]; + + if (!(formData instanceof FormData)) { + throw new TypeError("moderateEvent did not receive FormData."); + } + + return Array.from(formData.entries()); +} + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((promiseResolve) => { + resolve = promiseResolve; + }); + + return { promise, resolve }; +} + +describe("ModerationForm", () => { + beforeEach(() => { + serverActions.moderateEvent.mockResolvedValue({ + message: "Event approved and published.", + status: "success", + } satisfies ModerationFormState); + }); + + it("submits an approval with the bound event id and exposes pending and success states", async () => { + const user = userEvent.setup(); + const result = deferred(); + serverActions.moderateEvent.mockReturnValue(result.promise); + render(); + const form = screen.getByRole("form", { name: "Review Demo Night" }); + const formQueries = within(form); + + expect( + formQueries.getByText("Approval publishes this event to the calendar."), + ).toBeVisible(); + await user.type( + formQueries.getByLabelText("Note to submitter (optional)"), + "Looks good for the community calendar.", + ); + await user.click( + formQueries.getByRole("button", { name: "Approve and publish" }), + ); + + expect(serverActions.moderateEvent).toHaveBeenCalledTimes(1); + expect(serverActions.moderateEvent.mock.calls[0]?.[0]).toBe(EVENT_ID); + expect(serverActions.moderateEvent.mock.calls[0]?.[1]).toEqual({ + message: "", + status: "idle", + }); + expect(getSubmittedFormData()).toEqual([ + ["note", "Looks good for the community calendar."], + ["decision", "approved"], + ]); + for (const button of formQueries.getAllByRole("button", { name: "Saving…" })) { + expect(button).toBeDisabled(); + } + expect( + formQueries.getByLabelText("Note to submitter (optional)"), + ).toBeDisabled(); + + await act(async () => { + result.resolve({ + message: "Event approved and published.", + status: "success", + }); + await result.promise; + }); + + expect(await screen.findByRole("status")).toHaveTextContent( + "Event approved and published.", + ); + expect( + formQueries.getByLabelText("Note to submitter (optional)"), + ).toHaveValue(""); + }); + + it("submits a rejection and renders the error state returned by the server", async () => { + const user = userEvent.setup(); + serverActions.moderateEvent.mockResolvedValue({ + message: "Add a short note so the submitter knows what to change.", + status: "error", + } satisfies ModerationFormState); + render(); + const note = screen.getByLabelText("Note to submitter (optional)"); + await user.type(note, "No"); + await user.click(screen.getByRole("button", { name: "Reject with note" })); + + expect(serverActions.moderateEvent).toHaveBeenCalledTimes(1); + expect(serverActions.moderateEvent.mock.calls[0]?.[0]).toBe(EVENT_ID); + expect(getSubmittedFormData()).toEqual([ + ["note", "No"], + ["decision", "rejected"], + ]); + const alert = await screen.findByRole("alert"); + expect(alert).toHaveTextContent( + "Add a short note so the submitter knows what to change.", + ); + expect(note).toHaveValue("No"); + }); +}); diff --git a/app/admin/events/moderation-form.tsx b/app/admin/events/moderation-form.tsx index 7d3ea43..4fd72f2 100644 --- a/app/admin/events/moderation-form.tsx +++ b/app/admin/events/moderation-form.tsx @@ -1,6 +1,6 @@ "use client"; -import { useActionState } from "react"; +import { useActionState, useEffect, useState } from "react"; import { moderateEvent } from "@/lib/events/actions"; import { initialModerationFormState } from "@/lib/events/state"; import style from "./admin-events.module.css"; @@ -11,6 +11,7 @@ interface ModerationFormProps { } export function ModerationForm({ eventId, eventTitle }: ModerationFormProps) { + const [note, setNote] = useState(""); const moderateBoundEvent = moderateEvent.bind(null, eventId); const [state, formAction, pending] = useActionState( moderateBoundEvent, @@ -19,6 +20,15 @@ export function ModerationForm({ eventId, eventTitle }: ModerationFormProps) { const noteId = `moderation-${eventId}-note`; const noteHintId = `${noteId}-hint`; + useEffect(() => { + if (state.status === "success") { + // The Server Action resets native controls after every result. This + // controlled value intentionally clears only after a saved decision. + // eslint-disable-next-line react-hooks/set-state-in-effect + setNote(""); + } + }, [state]); + return (
setNote(event.target.value)} rows={3} + value={note} />

A rejection requires a note of at least 5 characters so the diff --git a/app/auth/auth-form.integration.test.tsx b/app/auth/auth-form.integration.test.tsx new file mode 100644 index 0000000..772a692 --- /dev/null +++ b/app/auth/auth-form.integration.test.tsx @@ -0,0 +1,168 @@ +// @vitest-environment jsdom + +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { AuthForm } from "./auth-form"; + +const authMocks = vi.hoisted(() => ({ + refresh: vi.fn(), + replace: vi.fn(), + signInEmail: vi.fn(), + signUpEmail: vi.fn(), +})); + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ + refresh: authMocks.refresh, + replace: authMocks.replace, + }), +})); + +vi.mock("@/lib/auth-client", () => ({ + authClient: { + signIn: { email: authMocks.signInEmail }, + signUp: { email: authMocks.signUpEmail }, + }, +})); + +async function fillCredentials( + user: ReturnType, + password = "correct horse", +) { + await user.type( + screen.getByRole("textbox", { name: "Email address" }), + "person@example.com", + ); + await user.type(screen.getByLabelText("Password"), password); +} + +describe("AuthForm", () => { + beforeEach(() => { + for (const mock of Object.values(authMocks)) { + mock.mockReset(); + } + }); + + it("signs in with the visible fields and navigates to the account", async () => { + const user = userEvent.setup(); + authMocks.signInEmail.mockResolvedValue({ error: null }); + + render(); + expect( + screen.getByRole("heading", { name: "Welcome back" }), + ).toBeVisible(); + expect( + screen.getByRole("button", { name: "Sign in", pressed: true }), + ).toBeVisible(); + + await fillCredentials(user); + await user.click(screen.getAllByRole("button", { name: "Sign in" }).at(-1)!); + + await waitFor(() => + expect(authMocks.signInEmail).toHaveBeenCalledWith({ + email: "person@example.com", + password: "correct horse", + }), + ); + expect(authMocks.signUpEmail).not.toHaveBeenCalled(); + expect(authMocks.replace).toHaveBeenCalledWith("/account"); + expect(authMocks.refresh).toHaveBeenCalledOnce(); + }); + + it("shows a friendly sign-in error without clearing entered credentials", async () => { + const user = userEvent.setup(); + authMocks.signInEmail.mockResolvedValue({ + error: { code: "INVALID_EMAIL_OR_PASSWORD" }, + }); + + render(); + await fillCredentials(user); + await user.click(screen.getAllByRole("button", { name: "Sign in" }).at(-1)!); + + expect( + await screen.findByText( + "That email and password combination did not match.", + ), + ).toBeVisible(); + expect(screen.getByRole("textbox", { name: "Email address" })).toHaveValue( + "person@example.com", + ); + expect(screen.getByLabelText("Password")).toHaveValue("correct horse"); + expect(authMocks.replace).not.toHaveBeenCalled(); + }); + + it("creates an account through the sign-up boundary", async () => { + const user = userEvent.setup(); + authMocks.signUpEmail.mockResolvedValue({ error: null }); + + render(); + await user.click(screen.getByRole("button", { name: "Create account" })); + expect( + screen.getByRole("heading", { name: "Create your account" }), + ).toBeVisible(); + + await user.type(screen.getByRole("textbox", { name: "Name" }), "Pat Lee"); + await fillCredentials(user, "a secure password"); + await user.click( + screen.getAllByRole("button", { name: "Create account" }).at(-1)!, + ); + + await waitFor(() => + expect(authMocks.signUpEmail).toHaveBeenCalledWith({ + email: "person@example.com", + name: "Pat Lee", + password: "a secure password", + }), + ); + expect(authMocks.signInEmail).not.toHaveBeenCalled(); + expect(authMocks.replace).toHaveBeenCalledWith("/account"); + expect(authMocks.refresh).toHaveBeenCalledOnce(); + }); + + it("maps duplicate-account errors and clears them when switching modes", async () => { + const user = userEvent.setup(); + authMocks.signUpEmail.mockResolvedValue({ + error: { code: "USER_ALREADY_EXISTS" }, + }); + + render(); + await user.click(screen.getByRole("button", { name: "Create account" })); + await user.type(screen.getByRole("textbox", { name: "Name" }), "Pat Lee"); + await fillCredentials(user); + await user.click( + screen.getAllByRole("button", { name: "Create account" }).at(-1)!, + ); + + expect( + await screen.findByText( + "An account already exists for that email. Try signing in instead.", + ), + ).toBeVisible(); + + await user.click(screen.getByRole("button", { name: "Sign in" })); + expect( + screen.queryByText( + "An account already exists for that email. Try signing in instead.", + ), + ).not.toBeInTheDocument(); + expect(screen.queryByRole("textbox", { name: "Name" })).not.toBeInTheDocument(); + }); + + it("reports a network failure without attempting navigation", async () => { + const user = userEvent.setup(); + authMocks.signInEmail.mockRejectedValue(new Error("offline")); + + render(); + await fillCredentials(user); + await user.click(screen.getAllByRole("button", { name: "Sign in" }).at(-1)!); + + expect( + await screen.findByText( + "We could not reach the account service. Check your connection and try again.", + ), + ).toBeVisible(); + expect(authMocks.replace).not.toHaveBeenCalled(); + expect(authMocks.refresh).not.toHaveBeenCalled(); + }); +}); diff --git a/app/events/events-page.integration.test.tsx b/app/events/events-page.integration.test.tsx new file mode 100644 index 0000000..d5ecb77 --- /dev/null +++ b/app/events/events-page.integration.test.tsx @@ -0,0 +1,251 @@ +// @vitest-environment jsdom + +import { within } from "@testing-library/dom"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it } from "vitest"; +import { Calendar } from "./components/calendar/calendar"; +import { RecurringEventsCard } from "./components/event-cards/recurring-event-card"; +import EventsPage from "./events-page"; +import type { Event, EventBlock, RecurrenceRule } from "./types"; + +const timeZone = "America/Los_Angeles"; + +function createBlock( + overrides: Partial & Pick, +): EventBlock { + return { + description: "A welcoming Sacramento technology gathering.", + location_address: "123 J Street, Sacramento, CA", + location_description: "Sacramento Central Library", + location_url: "https://events.example.com/details", + presenters: [], + slug: "event-occurrence", + timezone: timeZone, + title: "Event occurrence", + ...overrides, + }; +} + +function weeklyRule(overrides: Partial = {}): RecurrenceRule { + return { + endDate: null, + endType: "after_occurrences", + excludedDates: ["2026-09-09"], + frequency: "week", + interval: 1, + monthlyPattern: null, + occurrenceCount: 4, + weekdays: [3], + ...overrides, + }; +} + +function recurringEvent(overrides: Partial = {}): Event { + return { + blocks: [ + createBlock({ + ends_at: new Date("2026-09-02T18:00:00.000Z"), + slug: "typescript-weekly-seed", + starts_at: new Date("2026-09-02T17:00:00.000Z"), + title: "Sacramento TypeScript Weekly", + }), + ], + description: "Practice TypeScript with Sacramento developers.", + has_event_page: true, + in_person: false, + is_online: true, + is_recurring: true, + location_address: undefined, + location_description: "Online", + location_url: "https://events.example.com/details", + organizers: [], + recurrence_rule: weeklyRule(), + slug: "sacramento-typescript-weekly", + title: "Sacramento TypeScript Weekly", + ...overrides, + }; +} + +function specialEvent(): Event { + return { + blocks: [ + createBlock({ + ends_at: new Date("2026-09-05T21:00:00.000Z"), + location_description: "The Urban Hive", + slug: "design-summit-occurrence", + starts_at: new Date("2026-09-05T19:00:00.000Z"), + title: "Sacramento Design Summit", + }), + ], + description: "A one-day design community event.", + has_event_page: true, + in_person: true, + is_online: false, + is_recurring: false, + location_address: "123 J Street, Sacramento, CA", + location_description: "The Urban Hive", + location_url: "https://events.example.com/summit", + organizers: [], + recurrence_rule: null, + slug: "sacramento-design-summit", + title: "Sacramento Design Summit", + }; +} + +describe("public events experience", () => { + it("expands a series, omits its canceled date, and shows selected-day details", async () => { + const user = userEvent.setup(); + + render( + , + ); + + expect( + screen.getByRole("button", { name: "September 2, 2026, 1 event" }), + ).toBeVisible(); + expect( + screen.queryByRole("button", { name: "September 9, 2026, 1 event" }), + ).not.toBeInTheDocument(); + const septemberSixteenth = screen.getByRole("button", { + name: "September 16, 2026, 1 event", + }); + expect(septemberSixteenth).toBeVisible(); + + await user.click(septemberSixteenth); + const selectedDayHeading = screen.getByRole("heading", { + name: "September 16, 2026", + }); + const selectedDayDetails = selectedDayHeading.parentElement?.parentElement; + expect(selectedDayDetails).not.toBeNull(); + const selectedDay = within(selectedDayDetails as HTMLElement); + expect(selectedDay.getByText("Sacramento TypeScript Weekly")).toBeVisible(); + expect(selectedDay.getByText("10:00 AM")).toBeVisible(); + expect( + selectedDay.getByRole("link", { + name: "View details for Sacramento TypeScript Weekly", + }), + ).toHaveAttribute("href", "https://events.example.com/details"); + }); + + it("navigates the three-month calendar window", async () => { + const user = userEvent.setup(); + + render( + , + ); + expect( + screen.getByRole("table", { name: "September 2026" }), + ).toBeVisible(); + expect( + screen.getByRole("table", { name: "November 2026" }), + ).toBeVisible(); + + await user.click(screen.getByRole("button", { name: "Show next month" })); + + expect( + screen.queryByRole("table", { name: "September 2026" }), + ).not.toBeInTheDocument(); + expect( + screen.getByRole("table", { name: "December 2026" }), + ).toBeVisible(); + }); + + it("uses exclusions when choosing the next occurrence for the recurring card", () => { + render( +

    + +
, + ); + + const nextOccurrence = screen.getByText(/September 16/, { + selector: "time", + }); + expect(nextOccurrence).toHaveAttribute( + "datetime", + "2026-09-16T17:00:00.000Z", + ); + expect(screen.queryByText(/September 9/, { selector: "time" })).not.toBeInTheDocument(); + }); + + it("reports when exclusions consume every remaining finite occurrence", () => { + const event = recurringEvent({ + recurrence_rule: weeklyRule({ + excludedDates: ["2026-09-02", "2026-09-09"], + occurrenceCount: 2, + }), + }); + + render( +
    + +
, + ); + + expect( + screen.getByText("No upcoming dates are scheduled."), + ).toBeVisible(); + }); + + it("filters both the calendar and event-card collections by attendance type", async () => { + const user = userEvent.setup(); + + render( + , + ); + const filterGroup = screen.getByRole("group", { name: "Show" }); + const filters = within(filterGroup); + expect(screen.getByRole("status")).toHaveTextContent( + "Showing 2 events for all events.", + ); + expect( + screen.getByRole("heading", { name: "Sacramento TypeScript Weekly" }), + ).toBeVisible(); + expect( + screen.getByRole("heading", { name: "Sacramento Design Summit" }), + ).toBeVisible(); + + await user.click(filters.getByRole("radio", { name: "Online" })); + expect(screen.getByRole("status")).toHaveTextContent( + "Showing 1 event for online.", + ); + expect( + screen.getByRole("heading", { name: "Sacramento TypeScript Weekly" }), + ).toBeVisible(); + expect( + screen.queryByRole("heading", { name: "Sacramento Design Summit" }), + ).not.toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "September 2, 2026, 1 event" }), + ).toBeVisible(); + expect( + screen.queryByRole("button", { name: "September 5, 2026, 1 event" }), + ).not.toBeInTheDocument(); + + await user.click(filters.getByRole("radio", { name: "In person" })); + expect(screen.getByRole("status")).toHaveTextContent( + "Showing 1 event for in person.", + ); + expect( + screen.queryByRole("heading", { name: "Sacramento TypeScript Weekly" }), + ).not.toBeInTheDocument(); + expect( + screen.getByRole("heading", { name: "Sacramento Design Summit" }), + ).toBeVisible(); + expect( + screen.queryByRole("button", { name: "September 2, 2026, 1 event" }), + ).not.toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: "September 16, 2026, 1 event" }), + ).not.toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "September 5, 2026, 1 event" }), + ).toBeVisible(); + }); +}); diff --git a/app/events/submit/event-form.integration.test.tsx b/app/events/submit/event-form.integration.test.tsx new file mode 100644 index 0000000..be7ec16 --- /dev/null +++ b/app/events/submit/event-form.integration.test.tsx @@ -0,0 +1,380 @@ +// @vitest-environment jsdom + +import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { EventFormState } from "@/lib/events/state"; +import { EventForm } from "./event-form"; + +const serverActions = vi.hoisted(() => ({ + submitEvent: vi.fn(), +})); + +vi.mock("@/lib/events/actions", () => ({ + submitEvent: serverActions.submitEvent, +})); + +const TITLE = "SacTech Community Demo Night"; +const DESCRIPTION = + "See project demos from Sacramento developers and meet their creators."; +const STARTS_AT = "2099-05-19T10:00"; +const ENDS_AT = "2099-05-19T12:00"; + +const successState: EventFormState = { + message: "Event submitted for review.", + status: "success", +}; + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((promiseResolve) => { + resolve = promiseResolve; + }); + + return { promise, resolve }; +} + +function getSubmittedFormData(callIndex = 0) { + const formData = serverActions.submitEvent.mock.calls[callIndex]?.[1]; + + if (!(formData instanceof FormData)) { + throw new TypeError("submitEvent did not receive FormData."); + } + + return Array.from(formData.entries()); +} + +function eventEntries(recurrenceEntries: Array<[string, string]> = []) { + return [ + ["title", TITLE], + ["description", DESCRIPTION], + ["startsAt", STARTS_AT], + ["endsAt", ENDS_AT], + ...recurrenceEntries, + ["mode", "in_person"], + ["locationName", "Central Library"], + ["locationAddress", "828 I Street, Sacramento, CA"], + ["eventUrl", ""], + ]; +} + +async function fillRequiredEventFields(user: ReturnType) { + await user.type(screen.getByLabelText(/Event title/), TITLE); + await user.type(screen.getByLabelText(/Description/), DESCRIPTION); + fireEvent.change(screen.getByLabelText(/Starts/), { + target: { value: STARTS_AT }, + }); + fireEvent.change(screen.getByLabelText(/Ends/), { + target: { value: ENDS_AT }, + }); + await user.type(screen.getByLabelText("Venue or location name"), "Central Library"); + await user.type( + screen.getByLabelText("Street address"), + "828 I Street, Sacramento, CA", + ); +} + +async function enableRecurrence(user: ReturnType) { + await user.click( + screen.getByRole("checkbox", { name: /This event repeats/ }), + ); +} + +async function submitAndWaitForSuccess( + user: ReturnType, +) { + await user.click( + screen.getByRole("button", { name: "Submit event for review" }), + ); + await screen.findByRole("status"); +} + +describe("EventForm", () => { + beforeEach(() => { + serverActions.submitEvent.mockResolvedValue(successState); + }); + + it("submits a one-time event with only the visible event fields", async () => { + const user = userEvent.setup(); + render(); + await fillRequiredEventFields(user); + + expect( + screen.getByText(/Enter both times in Pacific time/), + ).toBeInTheDocument(); + expect( + screen.getByRole("checkbox", { name: /This event repeats/ }), + ).not.toBeChecked(); + + await submitAndWaitForSuccess(user); + + expect(serverActions.submitEvent).toHaveBeenCalledTimes(1); + expect(getSubmittedFormData()).toEqual(eventEntries()); + }); + + it("submits a daily series that never ends", async () => { + const user = userEvent.setup(); + render(); + await fillRequiredEventFields(user); + await enableRecurrence(user); + await user.selectOptions(screen.getByLabelText("Recurrence unit"), "day"); + + expect( + screen.getByText("Each occurrence starts at the same Pacific time."), + ).toBeVisible(); + expect(screen.queryByRole("group", { name: /Repeat on/ })).toBeNull(); + + await submitAndWaitForSuccess(user); + + expect(getSubmittedFormData()).toEqual( + eventEntries([ + ["recurring", "on"], + ["recurrenceInterval", "1"], + ["recurrenceFrequency", "day"], + ["recurrenceEndType", "never"], + ]), + ); + }); + + it("keeps the start weekday selected and submits added weekly days through an end date", async () => { + const user = userEvent.setup(); + render(); + await fillRequiredEventFields(user); + await enableRecurrence(user); + + const weekdayGroup = screen.getByRole("group", { name: /Repeat on/ }); + const tuesday = screen.getByRole("checkbox", { name: "Tuesday" }); + const thursday = screen.getByRole("checkbox", { name: "Thursday" }); + expect(weekdayGroup).toBeVisible(); + expect(tuesday).toBeChecked(); + + await user.click(tuesday); + expect(tuesday).toBeChecked(); + await user.click(thursday); + expect(thursday).toBeChecked(); + await user.click(screen.getByRole("radio", { name: "On date" })); + fireEvent.change(screen.getByLabelText("Recurrence end date"), { + target: { value: "2099-08-19" }, + }); + + await submitAndWaitForSuccess(user); + + expect(getSubmittedFormData()).toEqual( + eventEntries([ + ["recurring", "on"], + ["recurrenceInterval", "1"], + ["recurrenceFrequency", "week"], + ["recurrenceWeekdays", "2"], + ["recurrenceWeekdays", "4"], + ["recurrenceEndType", "on_date"], + ["recurrenceEndDate", "2099-08-19"], + ]), + ); + }); + + it("submits a monthly ordinal-weekday series after a chosen occurrence count", async () => { + const user = userEvent.setup(); + render(); + await fillRequiredEventFields(user); + await enableRecurrence(user); + await user.selectOptions(screen.getByLabelText("Recurrence unit"), "month"); + + const monthlyPattern = screen.getByLabelText("Monthly pattern"); + expect(monthlyPattern).toHaveDisplayValue("Day 19 of the month"); + expect( + screen.getByRole("option", { + name: "The 3rd Tuesday of the month", + }), + ).toBeInTheDocument(); + await user.selectOptions(monthlyPattern, "nth_weekday"); + await user.click(screen.getByRole("radio", { name: "After" })); + const occurrenceCount = screen.getByLabelText("Number of occurrences"); + await user.clear(occurrenceCount); + await user.type(occurrenceCount, "12"); + + await submitAndWaitForSuccess(user); + + expect(getSubmittedFormData()).toEqual( + eventEntries([ + ["recurring", "on"], + ["recurrenceInterval", "1"], + ["recurrenceFrequency", "month"], + ["recurrenceMonthlyPattern", "nth_weekday"], + ["recurrenceEndType", "after_occurrences"], + ["recurrenceCount", "12"], + ]), + ); + }); + + it("submits a yearly series and explains the start date that will repeat", async () => { + const user = userEvent.setup(); + render(); + await fillRequiredEventFields(user); + await enableRecurrence(user); + await user.selectOptions(screen.getByLabelText("Recurrence unit"), "year"); + const interval = screen.getByLabelText("Repeat every"); + await user.clear(interval); + await user.type(interval, "2"); + + const yearlyDate = screen.getByText("May 19"); + expect(yearlyDate.closest("p")).toHaveTextContent( + "Each year repeats on May 19 at the same Pacific time.", + ); + + await submitAndWaitForSuccess(user); + + expect(getSubmittedFormData()).toEqual( + eventEntries([ + ["recurring", "on"], + ["recurrenceInterval", "2"], + ["recurrenceFrequency", "year"], + ["recurrenceEndType", "never"], + ]), + ); + }); + + it("preserves entered values and recurrence controls when the server returns errors", async () => { + const user = userEvent.setup(); + serverActions.submitEvent.mockResolvedValue({ + errors: { + title: ["That title is already in use."], + }, + message: "Check the highlighted fields and try again.", + status: "error", + } satisfies EventFormState); + render(); + await fillRequiredEventFields(user); + await enableRecurrence(user); + await user.selectOptions(screen.getByLabelText("Recurrence unit"), "month"); + const recurrenceInterval = screen.getByLabelText("Repeat every"); + await user.clear(recurrenceInterval); + await user.type(recurrenceInterval, "3"); + await user.selectOptions( + screen.getByLabelText("Monthly pattern"), + "nth_weekday", + ); + await user.click(screen.getByRole("radio", { name: "After" })); + const count = screen.getByLabelText("Number of occurrences"); + await user.clear(count); + await user.type(count, "8"); + await user.click( + screen.getByRole("radio", { name: /^Hybrid/ }), + ); + await user.type( + screen.getByLabelText("Event or registration link"), + "https://example.com/demo-night", + ); + + await user.click( + screen.getByRole("button", { name: "Submit event for review" }), + ); + + const alert = await screen.findByRole("alert"); + expect(alert).toHaveTextContent("Check the highlighted fields and try again."); + expect(screen.getByText("That title is already in use.")).toBeVisible(); + expect(screen.getByLabelText(/Event title/)).toHaveValue(TITLE); + expect(screen.getByLabelText(/Description/)).toHaveValue(DESCRIPTION); + expect(screen.getByLabelText(/Starts/)).toHaveValue(STARTS_AT); + expect(screen.getByLabelText(/Ends/)).toHaveValue(ENDS_AT); + expect(screen.getByLabelText("Venue or location name")).toHaveValue( + "Central Library", + ); + expect(screen.getByLabelText("Street address")).toHaveValue( + "828 I Street, Sacramento, CA", + ); + expect(screen.getByLabelText("Event or registration link")).toHaveValue( + "https://example.com/demo-night", + ); + expect( + screen.getByRole("radio", { name: /^Hybrid/ }), + ).toBeChecked(); + expect( + screen.getByRole("checkbox", { name: /This event repeats/ }), + ).toBeChecked(); + expect(screen.getByLabelText("Recurrence unit")).toHaveValue("month"); + expect(screen.getByLabelText("Repeat every")).toHaveValue(3); + expect(screen.getByLabelText("Monthly pattern")).toHaveValue( + "nth_weekday", + ); + expect(screen.getByRole("radio", { name: "After" })).toBeChecked(); + expect(screen.getByLabelText("Number of occurrences")).toHaveValue(8); + expect(screen.getByLabelText(/Event title/)).toHaveAttribute( + "aria-invalid", + "true", + ); + }); + + it("locks the submitted draft while the server action is pending", async () => { + const user = userEvent.setup(); + const result = deferred(); + serverActions.submitEvent.mockReturnValue(result.promise); + render(); + await fillRequiredEventFields(user); + await enableRecurrence(user); + + await user.click( + screen.getByRole("button", { name: "Submit event for review" }), + ); + + const title = screen.getByLabelText(/Event title/); + const form = title.closest("form"); + expect(form).not.toBeNull(); + expect(form).toHaveAttribute("aria-busy", "true"); + expect(title).toBeDisabled(); + expect(screen.getByLabelText(/Description/)).toBeDisabled(); + expect(screen.getByLabelText(/Starts/)).toBeDisabled(); + expect(screen.getByLabelText("Recurrence unit")).toBeDisabled(); + expect(screen.getByRole("radio", { name: /^In person/ })).toBeDisabled(); + expect(screen.getByLabelText("Event or registration link")).toBeDisabled(); + expect( + screen.getByRole("button", { name: "Submitting…" }), + ).toBeDisabled(); + + await act(async () => { + result.resolve(successState); + await result.promise; + }); + + expect(await screen.findByRole("status")).toHaveTextContent( + "Event submitted for review.", + ); + expect(form).toHaveAttribute("aria-busy", "false"); + }); + + it("resets event and recurrence fields only after a successful submission", async () => { + const user = userEvent.setup(); + render(); + await fillRequiredEventFields(user); + await enableRecurrence(user); + await user.selectOptions(screen.getByLabelText("Recurrence unit"), "day"); + await user.click(screen.getByRole("radio", { name: /^Hybrid/ })); + await user.type( + screen.getByLabelText("Event or registration link"), + "https://example.com/demo-night", + ); + + await submitAndWaitForSuccess(user); + + await waitFor(() => { + expect(screen.getByLabelText(/Event title/)).toHaveValue(""); + expect(screen.getByLabelText(/Description/)).toHaveValue(""); + expect(screen.getByLabelText(/Starts/)).toHaveValue(""); + expect(screen.getByLabelText(/Ends/)).toHaveValue(""); + expect(screen.getByLabelText("Venue or location name")).toHaveValue(""); + expect(screen.getByLabelText("Street address")).toHaveValue(""); + expect(screen.getByLabelText("Event or registration link")).toHaveValue( + "", + ); + expect( + screen.getByRole("checkbox", { name: /This event repeats/ }), + ).not.toBeChecked(); + }); + expect(screen.queryByLabelText("Recurrence unit")).toBeNull(); + expect( + screen.getByRole("radio", { name: /^In person/ }), + ).toBeChecked(); + expect(screen.getByRole("status")).toHaveTextContent( + "Event submitted for review.", + ); + }); +}); diff --git a/app/events/submit/event-form.tsx b/app/events/submit/event-form.tsx index f9b002a..0318f79 100644 --- a/app/events/submit/event-form.tsx +++ b/app/events/submit/event-form.tsx @@ -29,6 +29,17 @@ interface RecurrenceDraft { count: string; } +interface EventDraft { + description: string; + endsAt: string; + eventUrl: string; + locationAddress: string; + locationName: string; + mode: "hybrid" | "in_person" | "online"; + startsAt: string; + title: string; +} + const WEEKDAYS = [ { label: "Sunday", shortLabel: "Sun", value: 0 }, { label: "Monday", shortLabel: "Mon", value: 1 }, @@ -65,6 +76,17 @@ const INITIAL_RECURRENCE: RecurrenceDraft = { weekdays: [0], }; +const INITIAL_EVENT_DRAFT: EventDraft = { + description: "", + endsAt: "", + eventUrl: "", + locationAddress: "", + locationName: "", + mode: "in_person", + startsAt: "", + title: "", +}; + function getStartDateParts(value: string) { const match = /^(\d{4})-(\d{2})-(\d{2})T/.exec(value); @@ -132,12 +154,11 @@ export function EventForm() { submitEvent, initialEventFormState, ); - const formRef = useRef(null); - const allowSuccessfulReset = useRef(false); const weekdaysCustomized = useRef(false); - const [startsAt, setStartsAt] = useState(""); + const [draft, setDraft] = useState(INITIAL_EVENT_DRAFT); const [recurrence, setRecurrence] = useState(INITIAL_RECURRENCE); const errors = state.errors; + const startsAt = draft.startsAt; const startDate = getStartDateParts(startsAt); const startWeekday = startDate ? WEEKDAYS[startDate.weekday].label : null; const monthlyDayLabel = startDate @@ -152,7 +173,7 @@ export function EventForm() { function handleStartChange(value: string) { const previousStartDate = getStartDateParts(startsAt); - setStartsAt(value); + setDraft((current) => ({ ...current, startsAt: value })); const nextStartDate = getStartDateParts(value); setRecurrence((current) => ({ ...current, @@ -196,33 +217,35 @@ export function EventForm() { }); } - // React resets uncontrolled fields after any resolved action, including validation errors. + // All user-entered fields are controlled because React resets native form + // controls after any resolved action, including returned validation errors. useEffect(() => { - if (state.status !== "success" || !formRef.current) { + if (state.status === "idle") { + return; + } + + if (state.status === "success") { + weekdaysCustomized.current = false; + // These controlled drafts intentionally clear only after the Server Action + // confirms success, never after a returned error. + // eslint-disable-next-line react-hooks/set-state-in-effect + setDraft(INITIAL_EVENT_DRAFT); + setRecurrence(INITIAL_RECURRENCE); return; } - allowSuccessfulReset.current = true; - formRef.current.reset(); - allowSuccessfulReset.current = false; + // React's post-action native reset can mutate radio and checkbox DOM state + // after the action-state render. Clone both controlled drafts once the error + // arrives so React reapplies the user's selections from the source of truth. + setDraft((current) => ({ ...current })); + setRecurrence((current) => ({ + ...current, + weekdays: [...current.weekdays], + })); }, [state]); return ( - { - if (!allowSuccessfulReset.current) { - event.preventDefault(); - return; - } - - weekdaysCustomized.current = false; - setStartsAt(""); - setRecurrence(INITIAL_RECURRENCE); - }} - ref={formRef} - > +

Event submission

What should the community know?

@@ -238,12 +261,20 @@ export function EventForm() { + setDraft((current) => ({ + ...current, + title: event.target.value, + })) + } required type="text" + value={draft.title} />

Use the name attendees will recognize. @@ -262,12 +293,20 @@ export function EventForm() { errors, )} aria-invalid={errors?.description?.length ? true : undefined} + disabled={pending} id="description" maxLength={4_000} minLength={20} name="description" + onChange={(event) => + setDraft((current) => ({ + ...current, + description: event.target.value, + })) + } required rows={7} + value={draft.description} />

Share what people will do, who the event is for, and anything @@ -276,7 +315,7 @@ export function EventForm() {

-
+
When it happens

Enter both times in Pacific time (America/Los_Angeles). We apply @@ -317,16 +356,26 @@ export function EventForm() { aria-invalid={errors?.endsAt?.length ? true : undefined} id="endsAt" name="endsAt" + onChange={(event) => + setDraft((current) => ({ + ...current, + endsAt: event.target.value, + })) + } required step={60} type="datetime-local" + value={draft.endsAt} />

-
+
Does it repeat?
-
+
Where people attend

A location name is required for in-person and hybrid events. An @@ -729,7 +799,14 @@ export function EventForm() { id="locationName" maxLength={200} name="locationName" + onChange={(event) => + setDraft((current) => ({ + ...current, + locationName: event.target.value, + })) + } type="text" + value={draft.locationName} />

For example, a coworking space, library, or community center. @@ -749,7 +826,14 @@ export function EventForm() { id="locationAddress" maxLength={500} name="locationAddress" + onChange={(event) => + setDraft((current) => ({ + ...current, + locationAddress: event.target.value, + })) + } type="text" + value={draft.locationAddress} />

Optional. Include the city and postal code when they help people @@ -771,8 +855,15 @@ export function EventForm() { inputMode="url" maxLength={2_048} name="eventUrl" + onChange={(event) => + setDraft((current) => ({ + ...current, + eventUrl: event.target.value, + })) + } placeholder="https://" type="url" + value={draft.eventUrl} />

Use a public http:// or https:// link where attendees can join diff --git a/lib/events/actions.integration.test.ts b/lib/events/actions.integration.test.ts new file mode 100644 index 0000000..7c0235b --- /dev/null +++ b/lib/events/actions.integration.test.ts @@ -0,0 +1,852 @@ +import { fileURLToPath } from "node:url"; +import { NetlifyDB } from "@netlify/database-dev"; +import { + afterAll, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from "vitest"; + +const { getCurrentSessionMock, revalidatePathMock } = vi.hoisted(() => ({ + getCurrentSessionMock: vi.fn(), + revalidatePathMock: vi.fn(), +})); + +vi.mock("@/lib/session", async (importOriginal) => { + const actual = await importOriginal(); + + return { + ...actual, + getCurrentSession: getCurrentSessionMock, + }; +}); + +vi.mock("next/cache", () => ({ + revalidatePath: revalidatePathMock, +})); + +// Vitest does not resolve the package's `react-server` condition. This marker +// has no runtime behavior in the application, so expose its intended empty +// server build while keeping every application dependency real. +vi.mock("server-only", () => ({})); + +const migrationsDirectory = fileURLToPath( + new URL("../../netlify/database/migrations", import.meta.url), +); +const FIXED_NOW = new Date("2026-08-19T19:00:00.000Z"); +const OWNER_ID = "event-owner"; +const OTHER_USER_ID = "other-event-owner"; +const ADMIN_ID = "event-admin"; +const PAST_RECURRING_EVENT_ID = "40000000-0000-4000-8000-000000000001"; + +const environmentKeys = [ + "BETTER_AUTH_ALLOWED_HOSTS", + "BETTER_AUTH_SCHEMA_GENERATION", + "BETTER_AUTH_SECRET", + "BETTER_AUTH_URL", + "NETLIFY_DB_DRIVER", + "NETLIFY_DB_URL", +] as const; +const originalEnvironment = Object.fromEntries( + environmentKeys.map((key) => [key, process.env[key]]), +) as Record<(typeof environmentKeys)[number], string | undefined>; + +const idleState = { message: "", status: "idle" } as const; + +function session(userId: string, role: string | null = null) { + return { user: { id: userId, role } }; +} + +interface SubmissionOptions { + startsAt?: string; + endsAt?: string; + recurring?: boolean; + weekday?: number; + title: string; +} + +function submissionForm({ + startsAt = "2026-09-01T18:00", + endsAt = "2026-09-01T20:00", + recurring = false, + weekday = 2, + title, +}: SubmissionOptions) { + const formData = new FormData(); + formData.set("title", title); + formData.set( + "description", + "A detailed Sacramento technology community event for integration testing.", + ); + formData.set("startsAt", startsAt); + formData.set("endsAt", endsAt); + formData.set("mode", "online"); + formData.set("locationName", ""); + formData.set("locationAddress", ""); + formData.set("eventUrl", "https://example.com/sacramento-event"); + + if (recurring) { + formData.set("recurring", "on"); + formData.set("recurrenceFrequency", "week"); + formData.set("recurrenceInterval", "1"); + formData.append("recurrenceWeekdays", String(weekday)); + formData.set("recurrenceEndType", "after_occurrences"); + formData.set("recurrenceCount", "5"); + } + + return formData; +} + +function moderationForm(decision: "approved" | "rejected", note = "") { + const formData = new FormData(); + formData.set("decision", decision); + formData.set("note", note); + return formData; +} + +function cancellationForm( + scope: "event" | "occurrence", + occurrenceDate?: string, +) { + const formData = new FormData(); + formData.set("scope", scope); + + if (occurrenceDate) { + formData.set("occurrenceDate", occurrenceDate); + } + + return formData; +} + +describe("event Server Actions and queries", () => { + const database = new NetlifyDB({ logger: () => undefined }); + let actions: typeof import("@/lib/events/actions"); + let queries: typeof import("@/lib/events/queries"); + let closeDrizzleClient: (() => Promise) | undefined; + let databaseStarted = false; + + async function findEventId(title: string) { + const stored = await database.query<{ id: string }>( + `SELECT id FROM "event" WHERE title = $1`, + [title], + ); + + if (!stored.rows[0]) { + throw new Error(`Expected an event titled "${title}" to be stored.`); + } + + return stored.rows[0].id; + } + + async function submitOneTimeEvent(title = "One-time Integration Event") { + getCurrentSessionMock.mockResolvedValue(session(OWNER_ID)); + const result = await actions.submitEvent( + idleState, + submissionForm({ title }), + ); + + if (result.status !== "success") { + throw new Error(`Could not seed one-time event: ${result.message}`); + } + + return findEventId(title); + } + + async function submitRecurringEvent({ + endsAt = "2026-09-02T20:00", + startsAt = "2026-09-02T18:00", + title = "Recurring Integration Series", + weekday = 3, + }: { + endsAt?: string; + startsAt?: string; + title?: string; + weekday?: number; + } = {}) { + getCurrentSessionMock.mockResolvedValue(session(OWNER_ID)); + const result = await actions.submitEvent( + idleState, + submissionForm({ + endsAt, + recurring: true, + startsAt, + title, + weekday, + }), + ); + + if (result.status !== "success") { + throw new Error(`Could not seed recurring event: ${result.message}`); + } + + return findEventId(title); + } + + async function approveEvent(eventId: string) { + getCurrentSessionMock.mockResolvedValue(session(ADMIN_ID, "admin")); + const result = await actions.moderateEvent( + eventId, + idleState, + moderationForm("approved"), + ); + + if (result.status !== "success") { + throw new Error(`Could not seed approved event: ${result.message}`); + } + } + + async function seedApprovedRecurringEvent() { + const eventId = await submitRecurringEvent(); + await approveEvent(eventId); + return eventId; + } + + beforeAll(async () => { + const databaseUrl = await database.start(); + databaseStarted = true; + process.env.NETLIFY_DB_URL = databaseUrl; + process.env.NETLIFY_DB_DRIVER = "server"; + process.env.BETTER_AUTH_SECRET = + "sactech-event-action-integration-test-secret"; + process.env.BETTER_AUTH_URL = "http://localhost:3000"; + process.env.BETTER_AUTH_ALLOWED_HOSTS = "localhost:3000"; + delete process.env.BETTER_AUTH_SCHEMA_GENERATION; + + await database.applyMigrations(migrationsDirectory); + await database.exec(` + INSERT INTO "user" (id, name, email, role) + VALUES + ('${OWNER_ID}', 'Event Owner', 'owner-events@sactech.test', NULL), + ('${OTHER_USER_ID}', 'Other Owner', 'other-events@sactech.test', NULL), + ('${ADMIN_ID}', 'Event Admin', 'admin-events@sactech.test', 'admin') + `); + + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(FIXED_NOW); + vi.resetModules(); + + const { db } = await import("@/db"); + const databaseClient: unknown = db.$client; + + if ( + typeof databaseClient === "object" && + databaseClient !== null && + "end" in databaseClient && + typeof databaseClient.end === "function" + ) { + closeDrizzleClient = databaseClient.end.bind(databaseClient); + } + + actions = await import("@/lib/events/actions"); + queries = await import("@/lib/events/queries"); + }); + + beforeEach(async () => { + getCurrentSessionMock.mockReset(); + revalidatePathMock.mockReset(); + await database.exec(`DELETE FROM "event"`); + }); + + afterAll(async () => { + try { + await closeDrizzleClient?.(); + } finally { + try { + if (databaseStarted) { + await database.stop(); + } + } finally { + vi.useRealTimers(); + + for (const key of environmentKeys) { + const originalValue = originalEnvironment[key]; + + if (originalValue === undefined) { + delete process.env[key]; + } else { + process.env[key] = originalValue; + } + } + + vi.resetModules(); + } + } + }); + + it("rejects unauthenticated submissions and cancellations before writing", async () => { + getCurrentSessionMock.mockResolvedValue(null); + + const submission = await actions.submitEvent( + idleState, + submissionForm({ title: "Unauthenticated Event" }), + ); + const cancellation = await actions.cancelEvent( + "10000000-0000-4000-8000-000000000001", + idleState, + cancellationForm("event"), + ); + const events = await database.query<{ count: number }>( + `SELECT COUNT(*)::integer AS count FROM "event"`, + ); + + expect(submission).toMatchObject({ status: "error" }); + expect(cancellation).toMatchObject({ status: "error" }); + expect(events.rows).toEqual([{ count: 0 }]); + expect(revalidatePathMock).not.toHaveBeenCalled(); + }); + + it("inserts a one-time event as one pending parent row", async () => { + getCurrentSessionMock.mockResolvedValue(session(OWNER_ID)); + + const result = await actions.submitEvent( + idleState, + submissionForm({ title: "One-time Integration Event" }), + ); + const stored = await database.query<{ + ends_at: Date; + id: string; + starts_at: Date; + status: string; + submitted_by: string; + timezone: string; + }>(` + SELECT id, submitted_by, starts_at, ends_at, timezone, status + FROM "event" + WHERE title = 'One-time Integration Event' + `); + const recurrenceRows = await database.query<{ count: number }>(` + SELECT COUNT(*)::integer AS count + FROM "event_recurrence" + WHERE event_id = '${stored.rows[0]?.id}' + `); + + expect(result).toMatchObject({ status: "success" }); + expect(stored.rows).toHaveLength(1); + expect(stored.rows[0]).toMatchObject({ + status: "pending", + submitted_by: OWNER_ID, + timezone: "America/Los_Angeles", + }); + expect(stored.rows[0].starts_at.toISOString()).toBe( + "2026-09-02T01:00:00.000Z", + ); + expect(stored.rows[0].ends_at.toISOString()).toBe( + "2026-09-02T03:00:00.000Z", + ); + expect(recurrenceRows.rows).toEqual([{ count: 0 }]); + expect(revalidatePathMock.mock.calls).toEqual([ + ["/account"], + ["/admin/events"], + ]); + }); + + it("inserts one recurring parent and one recurrence rule transactionally", async () => { + await submitOneTimeEvent(); + revalidatePathMock.mockClear(); + getCurrentSessionMock.mockResolvedValue(session(OWNER_ID)); + + const result = await actions.submitEvent( + idleState, + submissionForm({ + endsAt: "2026-09-02T20:00", + recurring: true, + startsAt: "2026-09-02T18:00", + title: "Recurring Integration Series", + weekday: 3, + }), + ); + const stored = await database.query<{ + end_type: string; + frequency: string; + id: string; + interval: number; + occurrence_count: number; + status: string; + weekdays: number[]; + }>(` + SELECT + e.id, + e.status, + r.frequency, + r."interval" AS interval, + r.weekdays, + r.end_type, + r.occurrence_count + FROM "event" e + JOIN "event_recurrence" r ON r.event_id = e.id + WHERE e.title = 'Recurring Integration Series' + `); + const pending = await queries.getPendingEvents(); + const account = await queries.getSubmissionsForUser(OWNER_ID); + const approved = await queries.getApprovedEvents(); + + expect(result).toMatchObject({ status: "success" }); + expect(stored.rows).toHaveLength(1); + expect(stored.rows[0]).toMatchObject({ + end_type: "after_occurrences", + frequency: "week", + interval: 1, + occurrence_count: 5, + status: "pending", + weekdays: [3], + }); + expect(pending.map((event) => event.title)).toEqual([ + "One-time Integration Event", + "Recurring Integration Series", + ]); + expect(account.map((event) => event.title).sort()).toEqual([ + "One-time Integration Event", + "Recurring Integration Series", + ]); + expect(approved).toEqual([]); + }); + + it("requires an admin and approves a recurring series only once", async () => { + const oneTimeEventId = await submitOneTimeEvent(); + const recurringEventId = await submitRecurringEvent(); + revalidatePathMock.mockClear(); + getCurrentSessionMock.mockResolvedValue(session(OWNER_ID)); + + const unauthorized = await actions.moderateEvent( + recurringEventId, + idleState, + moderationForm("approved"), + ); + expect(unauthorized).toMatchObject({ status: "error" }); + expect(revalidatePathMock).not.toHaveBeenCalled(); + + getCurrentSessionMock.mockResolvedValue(session(ADMIN_ID, "admin")); + const approvedResult = await actions.moderateEvent( + recurringEventId, + idleState, + moderationForm("approved"), + ); + + expect(approvedResult).toMatchObject({ status: "success" }); + expect(revalidatePathMock.mock.calls).toEqual([ + ["/events"], + ["/account"], + ["/admin/events"], + ]); + + revalidatePathMock.mockClear(); + const repeatedApproval = await actions.moderateEvent( + recurringEventId, + idleState, + moderationForm("approved"), + ); + const stored = await database.query<{ + recurrence_count: number; + reviewed_by: string; + status: string; + }>(` + SELECT + e.status, + e.reviewed_by, + COUNT(r.event_id)::integer AS recurrence_count + FROM "event" e + LEFT JOIN "event_recurrence" r ON r.event_id = e.id + WHERE e.id = '${recurringEventId}' + GROUP BY e.id + `); + const publicEvents = await queries.getApprovedEvents(); + const pending = await queries.getPendingEvents(); + const account = await queries.getSubmissionsForUser(OWNER_ID); + + expect(repeatedApproval).toMatchObject({ status: "error" }); + expect(revalidatePathMock).not.toHaveBeenCalled(); + expect(stored.rows).toEqual([ + { recurrence_count: 1, reviewed_by: ADMIN_ID, status: "approved" }, + ]); + expect(publicEvents).toHaveLength(1); + expect(publicEvents[0]).toMatchObject({ + is_recurring: true, + slug: recurringEventId, + title: "Recurring Integration Series", + }); + expect(publicEvents[0].recurrence_rule).toMatchObject({ + endType: "after_occurrences", + excludedDates: [], + frequency: "week", + occurrenceCount: 5, + }); + expect(pending.map((event) => event.id)).toEqual([oneTimeEventId]); + expect(account.find((event) => event.id === recurringEventId)?.status).toBe( + "approved", + ); + }); + + it("rejects a pending event with an account-visible moderation note", async () => { + getCurrentSessionMock.mockResolvedValue(session(OWNER_ID)); + await actions.submitEvent( + idleState, + submissionForm({ title: "Rejected Integration Event" }), + ); + const storedEvent = await database.query<{ id: string }>(` + SELECT id FROM "event" WHERE title = 'Rejected Integration Event' + `); + const rejectedEventId = storedEvent.rows[0].id; + revalidatePathMock.mockClear(); + + getCurrentSessionMock.mockResolvedValue(session(ADMIN_ID, "admin")); + const result = await actions.moderateEvent( + rejectedEventId, + idleState, + moderationForm( + "rejected", + "Please add more information about who should attend.", + ), + ); + const stored = await database.query<{ + moderation_note: string; + status: string; + }>(` + SELECT status, moderation_note + FROM "event" + WHERE id = '${rejectedEventId}' + `); + const accountEvent = (await queries.getSubmissionsForUser(OWNER_ID)).find( + (event) => event.id === rejectedEventId, + ); + + expect(result).toMatchObject({ status: "success" }); + expect(stored.rows).toEqual([ + { + moderation_note: + "Please add more information about who should attend.", + status: "rejected", + }, + ]); + expect(accountEvent).toMatchObject({ + moderationNote: + "Please add more information about who should attend.", + status: "rejected", + }); + expect( + (await queries.getPendingEvents()).some( + (event) => event.id === rejectedEventId, + ), + ).toBe(false); + expect( + (await queries.getApprovedEvents()).some( + (event) => event.slug === rejectedEventId, + ), + ).toBe(false); + expect(revalidatePathMock.mock.calls).toEqual([ + ["/events"], + ["/account"], + ["/admin/events"], + ]); + }); + + it("does not let another user cancel an event they do not own", async () => { + const recurringEventId = await seedApprovedRecurringEvent(); + revalidatePathMock.mockClear(); + getCurrentSessionMock.mockResolvedValue(session(OTHER_USER_ID)); + + const result = await actions.cancelEvent( + recurringEventId, + idleState, + cancellationForm("event"), + ); + const stored = await database.query<{ canceled_at: Date | null }>(` + SELECT canceled_at FROM "event" WHERE id = '${recurringEventId}' + `); + + expect(result).toMatchObject({ status: "error" }); + expect(stored.rows).toEqual([{ canceled_at: null }]); + expect(await queries.getApprovedEvents()).toHaveLength(1); + expect(revalidatePathMock).not.toHaveBeenCalled(); + }); + + it("lets the owner cancel a one-time event and prevents later moderation", async () => { + const oneTimeEventId = await submitOneTimeEvent(); + revalidatePathMock.mockClear(); + getCurrentSessionMock.mockResolvedValue(session(OWNER_ID)); + + const cancellation = await actions.cancelEvent( + oneTimeEventId, + idleState, + cancellationForm("event"), + ); + const stored = await database.query<{ + canceled_at: Date; + canceled_by: string; + status: string; + }>(` + SELECT status, canceled_at, canceled_by + FROM "event" + WHERE id = '${oneTimeEventId}' + `); + const accountEvent = (await queries.getSubmissionsForUser(OWNER_ID)).find( + (event) => event.id === oneTimeEventId, + ); + + expect(cancellation).toMatchObject({ status: "success" }); + expect(stored.rows[0]).toMatchObject({ + canceled_by: OWNER_ID, + status: "pending", + }); + expect(stored.rows[0].canceled_at).toEqual(FIXED_NOW); + expect(accountEvent?.canceledAt).toEqual(FIXED_NOW); + expect( + (await queries.getPendingEvents()).some( + (event) => event.id === oneTimeEventId, + ), + ).toBe(false); + expect(revalidatePathMock.mock.calls).toEqual([ + ["/events"], + ["/account"], + ["/admin/events"], + ]); + + revalidatePathMock.mockClear(); + getCurrentSessionMock.mockResolvedValue(session(ADMIN_ID, "admin")); + const moderation = await actions.moderateEvent( + oneTimeEventId, + idleState, + moderationForm("approved"), + ); + const unchanged = await database.query<{ + canceled_at: Date; + status: string; + }>(` + SELECT status, canceled_at + FROM "event" + WHERE id = '${oneTimeEventId}' + `); + + expect(moderation).toMatchObject({ status: "error" }); + expect(unchanged.rows).toEqual([ + { canceled_at: FIXED_NOW, status: "pending" }, + ]); + expect(revalidatePathMock).not.toHaveBeenCalled(); + }); + + it("rejects malformed, unscheduled, and past occurrence cancellations", async () => { + const recurringEventId = await seedApprovedRecurringEvent(); + revalidatePathMock.mockClear(); + getCurrentSessionMock.mockResolvedValue(session(OWNER_ID)); + await database.exec(` + INSERT INTO "event" ( + id, + submitted_by, + title, + description, + starts_at, + ends_at, + mode, + status + ) + VALUES ( + '${PAST_RECURRING_EVENT_ID}', + '${OWNER_ID}', + 'Past Recurring Integration Series', + 'A historical recurring event used to test cancellation guards.', + '2026-08-05T01:00:00Z', + '2026-08-05T03:00:00Z', + 'online', + 'rejected' + ); + INSERT INTO "event_recurrence" ( + event_id, + frequency, + "interval", + weekdays, + end_type + ) + VALUES ( + '${PAST_RECURRING_EVENT_ID}', + 'week', + 1, + ARRAY[1], + 'never' + ) + `); + + const malformed = await actions.cancelEvent( + recurringEventId, + idleState, + cancellationForm("occurrence", "September 9"), + ); + const unscheduled = await actions.cancelEvent( + recurringEventId, + idleState, + cancellationForm("occurrence", "2026-09-03"), + ); + const past = await actions.cancelEvent( + PAST_RECURRING_EVENT_ID, + idleState, + cancellationForm("occurrence", "2026-08-10"), + ); + const cancellations = await database.query<{ count: number }>( + `SELECT COUNT(*)::integer AS count FROM "event_occurrence_cancellation"`, + ); + + expect(malformed).toMatchObject({ status: "error" }); + expect(unscheduled).toMatchObject({ + message: "That date is not a scheduled occurrence of this event.", + status: "error", + }); + expect(past).toMatchObject({ + message: "Past event occurrences cannot be canceled.", + status: "error", + }); + expect(cancellations.rows).toEqual([{ count: 0 }]); + expect(revalidatePathMock).not.toHaveBeenCalled(); + }); + + it("stores one valid occurrence cancellation and rejects a duplicate", async () => { + const recurringEventId = await seedApprovedRecurringEvent(); + revalidatePathMock.mockClear(); + getCurrentSessionMock.mockResolvedValue(session(OWNER_ID)); + + const result = await actions.cancelEvent( + recurringEventId, + idleState, + cancellationForm("occurrence", "2026-09-09"), + ); + const stored = await database.query<{ + canceled_by: string; + event_id: string; + occurrence_date: Date; + }>(` + SELECT event_id, occurrence_date, canceled_by + FROM "event_occurrence_cancellation" + WHERE event_id = '${recurringEventId}' + `); + const publicEvent = (await queries.getApprovedEvents())[0]; + const accountEvent = (await queries.getSubmissionsForUser(OWNER_ID)).find( + (event) => event.id === recurringEventId, + ); + + expect(result).toMatchObject({ status: "success" }); + expect(stored.rows).toHaveLength(1); + expect(stored.rows[0]).toMatchObject({ + canceled_by: OWNER_ID, + event_id: recurringEventId, + }); + expect(stored.rows[0].occurrence_date.toISOString()).toBe( + "2026-09-09T00:00:00.000Z", + ); + expect(publicEvent.recurrence_rule?.excludedDates).toEqual([ + "2026-09-09", + ]); + expect(accountEvent?.canceledOccurrences).toEqual(["2026-09-09"]); + expect(revalidatePathMock.mock.calls).toEqual([ + ["/events"], + ["/account"], + ["/admin/events"], + ]); + + revalidatePathMock.mockClear(); + const duplicate = await actions.cancelEvent( + recurringEventId, + idleState, + cancellationForm("occurrence", "2026-09-09"), + ); + const count = await database.query<{ count: number }>(` + SELECT COUNT(*)::integer AS count + FROM "event_occurrence_cancellation" + WHERE event_id = '${recurringEventId}' + `); + + expect(duplicate).toMatchObject({ + message: "That event occurrence is already canceled.", + status: "error", + }); + expect(count.rows).toEqual([{ count: 1 }]); + expect(revalidatePathMock).not.toHaveBeenCalled(); + }); + + it("shows pending exceptions to admins and the event owner", async () => { + const pendingRecurringEventId = await submitRecurringEvent({ + endsAt: "2026-09-03T20:00", + startsAt: "2026-09-03T18:00", + title: "Pending Recurring Integration Series", + weekday: 4, + }); + revalidatePathMock.mockClear(); + getCurrentSessionMock.mockResolvedValue(session(OWNER_ID)); + + const cancellation = await actions.cancelEvent( + pendingRecurringEventId, + idleState, + cancellationForm("occurrence", "2026-09-10"), + ); + const pendingEvent = (await queries.getPendingEvents()).find( + (event) => event.id === pendingRecurringEventId, + ); + const accountEvent = (await queries.getSubmissionsForUser(OWNER_ID)).find( + (event) => event.id === pendingRecurringEventId, + ); + + expect(cancellation).toMatchObject({ status: "success" }); + expect(pendingEvent?.canceledOccurrences).toEqual(["2026-09-10"]); + expect(accountEvent?.canceledOccurrences).toEqual(["2026-09-10"]); + }); + + it("immediately hides a whole canceled series without changing its approval", async () => { + const recurringEventId = await seedApprovedRecurringEvent(); + getCurrentSessionMock.mockResolvedValue(session(OWNER_ID)); + const exception = await actions.cancelEvent( + recurringEventId, + idleState, + cancellationForm("occurrence", "2026-09-09"), + ); + + if (exception.status !== "success") { + throw new Error(`Could not seed occurrence exception: ${exception.message}`); + } + + const pendingRecurringEventId = await submitRecurringEvent({ + endsAt: "2026-09-03T20:00", + startsAt: "2026-09-03T18:00", + title: "Pending Recurring Integration Series", + weekday: 4, + }); + revalidatePathMock.mockClear(); + getCurrentSessionMock.mockResolvedValue(session(OWNER_ID)); + + const result = await actions.cancelEvent( + recurringEventId, + idleState, + cancellationForm("event"), + ); + const stored = await database.query<{ + canceled_at: Date; + canceled_by: string; + status: string; + }>(` + SELECT status, canceled_at, canceled_by + FROM "event" + WHERE id = '${recurringEventId}' + `); + const publicEvents = await queries.getApprovedEvents(); + const pending = await queries.getPendingEvents(); + const accountEvent = (await queries.getSubmissionsForUser(OWNER_ID)).find( + (event) => event.id === recurringEventId, + ); + + expect(result).toMatchObject({ status: "success" }); + expect(stored.rows[0]).toMatchObject({ + canceled_by: OWNER_ID, + status: "approved", + }); + expect(stored.rows[0].canceled_at).toEqual(FIXED_NOW); + expect(publicEvents).toEqual([]); + expect(pending.map((event) => event.id)).toEqual([ + pendingRecurringEventId, + ]); + expect(accountEvent?.canceledAt).toEqual(FIXED_NOW); + expect(accountEvent?.canceledOccurrences).toEqual(["2026-09-09"]); + expect(revalidatePathMock.mock.calls).toEqual([ + ["/events"], + ["/account"], + ["/admin/events"], + ]); + }); +}); diff --git a/package.json b/package.json index 4531ef7..59831cc 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "lint": "eslint .", "typecheck": "tsc --noEmit", "test": "vitest run", + "test:watch": "vitest", "db:generate": "drizzle-kit generate", "db:migrate": "netlify database migrations apply", "db:status": "netlify database status", @@ -36,13 +37,19 @@ }, "devDependencies": { "@netlify/database-dev": "^1.0.0", + "@testing-library/dom": "^10.4.1", + "@testing-library/jest-dom": "^7.0.1", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.5", "@types/node": "^26.2.0", "@types/react": "^19.1.8", "@types/react-dom": "^19.1.6", + "@vitejs/plugin-react": "^6.0.5", "auth": "^1.7.1", "drizzle-kit": "1.0.0-rc.4", + "jsdom": "^30.0.1", "netlify-cli": "^27.1.2", "typescript": "^6.0.3", - "vitest": "^4.1.11" + "vitest": "5.0.0-beta.7" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4513ecc..c927b8a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -16,7 +16,7 @@ importers: version: 2.0.0 better-auth: specifier: 1.7.1 - version: 1.7.1(@opentelemetry/api@1.9.1)(drizzle-kit@1.0.0-rc.4)(drizzle-orm@1.0.0-rc.4(@neondatabase/serverless@1.1.0)(@opentelemetry/api@1.9.1)(pg@8.23.0)(zod@4.4.3))(next@16.3.1(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@types/node@26.2.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(pg@8.23.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(vitest@4.1.11(@opentelemetry/api@1.9.1)(@types/node@26.2.0)(vite@8.2.1(@types/node@26.2.0)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0))) + version: 1.7.1(@opentelemetry/api@1.9.1)(drizzle-kit@1.0.0-rc.4)(drizzle-orm@1.0.0-rc.4(@neondatabase/serverless@1.1.0)(@opentelemetry/api@1.9.1)(pg@8.23.0)(zod@4.4.3))(next@16.3.1(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@types/node@26.2.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(pg@8.23.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(vitest@5.0.0-beta.7(@opentelemetry/api@1.9.1)(@types/node@26.2.0)(jsdom@30.0.1(@noble/hashes@2.3.0))(vite@8.2.1(@types/node@26.2.0)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0))) dayjs: specifier: ^1.11.23 version: 1.11.23 @@ -48,6 +48,18 @@ importers: '@netlify/database-dev': specifier: ^1.0.0 version: 1.0.0 + '@testing-library/dom': + specifier: ^10.4.1 + version: 10.4.1 + '@testing-library/jest-dom': + specifier: ^7.0.1 + version: 7.0.1(@testing-library/dom@10.4.1)(vitest@5.0.0-beta.7(@opentelemetry/api@1.9.1)(@types/node@26.2.0)(jsdom@30.0.1(@noble/hashes@2.3.0))(vite@8.2.1(@types/node@26.2.0)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0))) + '@testing-library/react': + specifier: ^16.3.2 + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@testing-library/user-event': + specifier: ^14.6.5 + version: 14.6.5(@testing-library/dom@10.4.1) '@types/node': specifier: ^26.2.0 version: 26.2.0 @@ -57,12 +69,18 @@ importers: '@types/react-dom': specifier: ^19.1.6 version: 19.2.4(@types/react@19.2.18) + '@vitejs/plugin-react': + specifier: ^6.0.5 + version: 6.0.5(vite@8.2.1(@types/node@26.2.0)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)) auth: specifier: ^1.7.1 - version: 1.7.1(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.4.3))(chokidar@5.0.0)(drizzle-kit@1.0.0-rc.4)(drizzle-orm@1.0.0-rc.4(@neondatabase/serverless@1.1.0)(@opentelemetry/api@1.9.1)(pg@8.23.0)(zod@4.4.3))(jose@6.2.9)(kysely@0.29.5)(nanostores@1.5.1)(next@16.3.1(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@types/node@26.2.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(pg@8.23.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(vitest@4.1.11(@opentelemetry/api@1.9.1)(@types/node@26.2.0)(vite@8.2.1(@types/node@26.2.0)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0))) + version: 1.7.1(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.4.3))(chokidar@5.0.0)(drizzle-kit@1.0.0-rc.4)(drizzle-orm@1.0.0-rc.4(@neondatabase/serverless@1.1.0)(@opentelemetry/api@1.9.1)(pg@8.23.0)(zod@4.4.3))(jose@6.2.9)(kysely@0.29.5)(nanostores@1.5.1)(next@16.3.1(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@types/node@26.2.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(pg@8.23.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(vitest@5.0.0-beta.7(@opentelemetry/api@1.9.1)(@types/node@26.2.0)(jsdom@30.0.1(@noble/hashes@2.3.0))(vite@8.2.1(@types/node@26.2.0)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0))) drizzle-kit: specifier: 1.0.0-rc.4 version: 1.0.0-rc.4 + jsdom: + specifier: ^30.0.1 + version: 30.0.1(@noble/hashes@2.3.0) netlify-cli: specifier: ^27.1.2 version: 27.1.2(@types/node@26.2.0)(picomatch@4.0.5) @@ -70,11 +88,22 @@ importers: specifier: ^6.0.3 version: 6.0.3 vitest: - specifier: ^4.1.11 - version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@26.2.0)(vite@8.2.1(@types/node@26.2.0)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)) + specifier: 5.0.0-beta.7 + version: 5.0.0-beta.7(@opentelemetry/api@1.9.1)(@types/node@26.2.0)(jsdom@30.0.1(@noble/hashes@2.3.0))(vite@8.2.1(@types/node@26.2.0)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)) packages: + '@adobe/css-tools@4.5.0': + resolution: {integrity: sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==} + + '@asamuzakjp/css-color@6.0.7': + resolution: {integrity: sha512-vC/bk1Lz7Tn/EfU9/apOTBk80/8dyGyWMowPoV1tJ52muDGsDqt2HPT2klrFUiY60MQmQv9q8yIht15JnBgDGw==} + engines: {node: ^22.13.0 || >=24.0.0} + + '@asamuzakjp/dom-selector@8.3.2': + resolution: {integrity: sha512-93Z1N+BQNXysodoicpOIyNh2drHfz/CTf9nnT0FEx72GJcIiwgydD7tGAr78j41LsYn3hlRn+LdGPuBLn1Bl8Q==} + engines: {node: ^22.13.0 || >=24.0.0} + '@babel/code-frame@7.29.7': resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} engines: {node: '>=6.9.0'} @@ -222,6 +251,10 @@ packages: peerDependencies: '@babel/core': ^7.0.0-0 + '@babel/runtime@7.29.7': + resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} + engines: {node: '>=6.9.0'} + '@babel/template@7.29.7': resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==} engines: {node: '>=6.9.0'} @@ -316,6 +349,10 @@ packages: '@better-fetch/fetch@1.3.1': resolution: {integrity: sha512-ABkD1WhyfPZprKRQI3bhATjeiFuNWC9PXhfGWqL+sg/gKrM977oFrYkdb4msM3hgUGonr7KlOsOFT5TU2rht9g==} + '@bramus/specificity@2.4.2': + resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==} + hasBin: true + '@bugsnag/browser@8.10.0': resolution: {integrity: sha512-RucooYQMoLSD+aU3YOh3d22/rgqIt0pDr6bgSokvandS0mt1IOK318QwEsBq6aZ68j7vhhMt84Z3Tdum64QsOA==} @@ -365,6 +402,42 @@ packages: resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} engines: {node: '>=12'} + '@csstools/color-helpers@6.1.1': + resolution: {integrity: sha512-gLNsunvwf3mCi5u5o46/Z/JcJMnhbHSaZ69rkgPzNM3J4s8hWwpPUQB6/tt0EDFyCiWzxANlx+2LJwpYj4zS1w==} + engines: {node: '>=20.19.0'} + + '@csstools/css-calc@3.3.0': + resolution: {integrity: sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-color-parser@4.2.0': + resolution: {integrity: sha512-5+5LEmFuY1AjXdYhmgjTJogtQnP1evJ1zrBZGUNZ0thkpwnnmKxcHdAMn/OtFjAb25zA+jKDVYVRl+5G7rjv1A==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-parser-algorithms@4.0.0': + resolution: {integrity: sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.8': + resolution: {integrity: sha512-CpMLjAvwQg3BL5S0IeqsZNMH7EQrEWi0kLKOC13ZBF0ZwERiLWlibNPJr8G1kdU3Ms/r2KiNrF81pUh2HwAHdg==} + peerDependencies: + css-tree: ^3.2.1 + peerDependenciesMeta: + css-tree: + optional: true + + '@csstools/css-tokenizer@4.0.0': + resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==} + engines: {node: '>=20.19.0'} + '@dabh/diagnostics@2.0.8': resolution: {integrity: sha512-R4MSXTVnuMzGD7bzHdW2ZhhdPC/igELENcq5IjEverBvq5hn1SXCWcsi6eSsdWP0/Ur+SItRRjAktmdoX/8R/Q==} @@ -750,6 +823,15 @@ packages: resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@exodus/bytes@1.15.1': + resolution: {integrity: sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + peerDependencies: + '@noble/hashes': ^1.8.0 || ^2.0.0 + peerDependenciesMeta: + '@noble/hashes': + optional: true + '@fastify/accept-negotiator@2.1.0': resolution: {integrity: sha512-F3EVbzWt+xcnVaOHmWyIlpuFtbxOln7HDZQsh09MtMmMm/CipMayNt8hnIL8VQi54u2ZociDbf+iluGYkf7B1A==} @@ -1801,6 +1883,41 @@ packages: '@swc/helpers@0.5.23': resolution: {integrity: sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==} + '@testing-library/dom@10.4.1': + resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} + engines: {node: '>=18'} + + '@testing-library/jest-dom@7.0.1': + resolution: {integrity: sha512-oMDTC3oA+6CXSO2JZnvOI7CA6oVub6kij5ggk9ohwye5slmkwxYDXcPOVxgMw/RQlticjtO0C1RZkR97HgrWMw==} + engines: {node: '>=22', npm: '>=6', yarn: '>=1'} + peerDependencies: + '@testing-library/dom': '>=10 <11' + vitest: '>= 0.32' + peerDependenciesMeta: + vitest: + optional: true + + '@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.0.0 || ^19.0.0 + '@types/react-dom': ^18.0.0 || ^19.0.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 + + '@testing-library/user-event@14.6.5': + resolution: {integrity: sha512-FhqjldLTpteueBaKflhNFlMT3+PM0O5fiBUivht6b9CZ1eesJyy7+g3Jr7XwJzt/Hip3ZG5hWwK1MX1FuDiE4w==} + engines: {node: '>=12', npm: '>=6'} + peerDependencies: + '@testing-library/dom': '>=7.21.4' + '@tsconfig/node10@1.0.13': resolution: {integrity: sha512-gcLdvR9HO1ZJBypsOGqaP6TFEzb6vIta0KSTLt9NAQ6pXQO3cRgSVyCN6pzYqI9DlJgY71XKO0dpDhCf08b3pg==} @@ -1816,6 +1933,9 @@ packages: '@tybys/wasm-util@0.10.3': resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==} + '@types/aria-query@5.0.4': + resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} + '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} @@ -2041,11 +2161,21 @@ packages: engines: {node: '>=18'} hasBin: true - '@vitest/expect@4.1.11': - resolution: {integrity: sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==} + '@vitejs/plugin-react@6.0.5': + resolution: {integrity: sha512-BOVzne/NL162sMdResB25mUv+vWMF5NoAjNf09TeGlE7ZpszZWSD3winycicLJw72yeVsoCn/2kOhEuCvEShMA==} + 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/mocker@4.1.11': - resolution: {integrity: sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==} + '@vitest/mocker@5.0.0-beta.7': + resolution: {integrity: sha512-SczTGMNh6ksM5r8ogUwAf8zVbQPgbc6yvdZO9xBsTKT2rLUwpHn95Upod0s0Cpm3/bH898iqRBLXovRZSUbJBg==} peerDependencies: msw: ^2.4.9 vite: ^6.0.0 || ^7.0.0 || ^8.0.0 @@ -2055,20 +2185,8 @@ packages: vite: optional: true - '@vitest/pretty-format@4.1.11': - resolution: {integrity: sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==} - - '@vitest/runner@4.1.11': - resolution: {integrity: sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==} - - '@vitest/snapshot@4.1.11': - resolution: {integrity: sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==} - - '@vitest/spy@4.1.11': - resolution: {integrity: sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==} - - '@vitest/utils@4.1.11': - resolution: {integrity: sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==} + '@vitest/spy@5.0.0-beta.7': + resolution: {integrity: sha512-NiE4frPDnDjFaMvaZaj4ASyxc/c2fvSQU8Fpo7D0YNj9AetE4hcA7ewRxhlrg6dHEOt1JhhCgy67e8SCXDh5VQ==} '@vue/compiler-core@3.5.41': resolution: {integrity: sha512-q0Xtv/F9w2YO/7htQhtiL+Ev2WCJbe5N2hc+XfgyKkEKqWpSxknmT8QOuGdEKNdjPq0c3F7rNpFkTo3Kfrm7pg==} @@ -2193,6 +2311,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'} @@ -2220,6 +2342,9 @@ packages: argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + 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'} @@ -2453,6 +2578,9 @@ packages: zod: optional: true + bidi-js@1.0.3: + resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==} + bindings@1.5.0: resolution: {integrity: sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==} @@ -2815,6 +2943,9 @@ packages: resolution: {integrity: sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==} engines: {node: '>= 6'} + css.escape@1.5.1: + resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==} + cssfilter@0.0.10: resolution: {integrity: sha512-FAaLDaplstoRsDR8XGYH51znUN0UY7nMc6Z9/fvE8EXGwvJE9hu7W2vHwx1+bd6gCYnln9nLbzxFTrcO9YQDZw==} @@ -2835,6 +2966,10 @@ packages: resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==} engines: {node: '>= 12'} + data-urls@7.0.0: + resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + data-view-buffer@1.0.2: resolution: {integrity: sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==} engines: {node: '>= 0.4'} @@ -2870,6 +3005,9 @@ packages: decache@4.6.2: resolution: {integrity: sha512-2LPqkLeu8XWHU8qNCS3kcF6sCcb5zIzvWaAHYSvPfwhdd7mHuah29NssMzrTYyHN4F5oFy2ko9OBYxegtU0FEw==} + decimal.js@10.6.0: + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + deep-extend@0.6.0: resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==} engines: {node: '>=4.0.0'} @@ -2976,6 +3114,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-serializer@2.0.0: resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==} @@ -3209,6 +3353,10 @@ packages: resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} engines: {node: '>=0.12'} + entities@8.0.0: + resolution: {integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==} + engines: {node: '>=20.19.0'} + env-paths@3.0.0: resolution: {integrity: sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} @@ -3860,6 +4008,10 @@ packages: resolution: {integrity: sha512-IoyXi0eBFLbaARl6IjQxNzxqNxH3h1XX026PP5uru/RwVVLOt9Qr9f87GW+4L/sj7sNyMGzmIsh++0z5ibkP5w==} engines: {node: '>=16.0.0'} + html-encoding-sniffer@6.0.0: + resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + http-errors@2.0.0: resolution: {integrity: sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==} engines: {node: '>= 0.8'} @@ -3926,6 +4078,10 @@ packages: resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} engines: {node: '>=0.8.19'} + indent-string@4.0.0: + resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} + engines: {node: '>=8'} + indent-string@5.0.0: resolution: {integrity: sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg==} engines: {node: '>=12'} @@ -4112,6 +4268,9 @@ packages: resolution: {integrity: sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==} engines: {node: '>=0.10.0'} + is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + is-promise@4.0.0: resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} @@ -4223,6 +4382,15 @@ packages: jsbi@4.3.2: resolution: {integrity: sha512-9fqMSQbhJykSeii05nxKl4m6Eqn2P6rOlYiS+C5Dr/HPIU/7yZxu5qzbs40tgaFORiw2Amd0mirjxatXYMkIew==} + jsdom@30.0.1: + resolution: {integrity: sha512-52v7mUVUfNQVYYqE1lcdaymWL0njO7lTLUog6ZvW2U5KsbiLk/GnZlVJ+qx0xfNJZ6Gn+KSpPNE52vurbxZwrA==} + engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} + peerDependencies: + canvas: ^3.2.3 + peerDependenciesMeta: + canvas: + optional: true + jsesc@3.1.0: resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} engines: {node: '>=6'} @@ -4503,6 +4671,10 @@ packages: resolution: {integrity: sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==} engines: {node: '>=12'} + lz-string@1.5.0: + resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} + hasBin: true + macos-release@3.5.1: resolution: {integrity: sha512-Lci/1in+elqZ589PXnfP/iwZXpwQifTM94WJRQwG2tZSdfY7NfB/aUaTARHrohWCgHlXoabeaeXRDOnF5X9JQw==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} @@ -4510,6 +4682,9 @@ packages: magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + magic-string@1.2.1: + resolution: {integrity: sha512-vCfXkt3lIJha02CjPT1igeysyHVfCsEpIeD20O+X9aJ2hML3/kKx8E9Iv1FB+aMSAlDOEAtpRWzuooQCrYwdUg==} + make-error@1.3.6: resolution: {integrity: sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==} @@ -4587,6 +4762,10 @@ packages: resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==} engines: {node: '>=18'} + min-indent@1.0.1: + resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} + engines: {node: '>=4'} + minimatch@10.2.6: resolution: {integrity: sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==} engines: {node: 18 || 20 || >=22} @@ -4958,6 +5137,9 @@ packages: resolution: {integrity: sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==} engines: {node: '>=18'} + parse5@8.0.1: + resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==} + parseurl@1.3.3: resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} engines: {node: '>= 0.8'} @@ -5126,6 +5308,10 @@ packages: engines: {node: '>=14'} hasBin: true + 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-ms@9.3.0: resolution: {integrity: sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==} engines: {node: '>=18'} @@ -5222,6 +5408,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@19.2.8: resolution: {integrity: sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==} engines: {node: '>=0.10.0'} @@ -5272,6 +5461,10 @@ packages: real-require@1.0.0: resolution: {integrity: sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==} + redent@3.0.0: + resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} + engines: {node: '>=8'} + reflect.getprototypeof@1.0.10: resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==} engines: {node: '>= 0.4'} @@ -5414,6 +5607,10 @@ packages: resolution: {integrity: sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q==} engines: {node: '>=11.0.0'} + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + scheduler@0.27.0: resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} @@ -5661,6 +5858,10 @@ packages: resolution: {integrity: sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==} engines: {node: '>=12'} + 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'} @@ -5713,6 +5914,9 @@ packages: engines: {node: '>=16'} 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'} @@ -5751,8 +5955,9 @@ packages: through@2.3.8: resolution: {integrity: sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==} - tinybench@2.9.0: - resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + tinybench@6.1.3: + resolution: {integrity: sha512-8k25iNSZHnzkjp3nrpEmx6YkrTNs41PzOHYc28DR5Z2l/CId/Nzw+Ume/41jCeDDmCUMPuK5GkQ/VxJaJ2P6Qg==} + engines: {node: '>=20.0.0'} tinyclip@0.1.15: resolution: {integrity: sha512-uo33abH+Ays0xYaDysoBt494Hb3hsEczMpcC0MwFl773pazORx4fmvKhclhR1wonUbB6vvpRsvVMwnhfqeMc+A==} @@ -5766,9 +5971,12 @@ packages: resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} engines: {node: '>=12.0.0'} - tinyrainbow@3.1.1: - resolution: {integrity: sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==} - engines: {node: '>=14.0.0'} + tldts-core@7.4.10: + resolution: {integrity: sha512-KnQjp53ZekKgm/r3l+u8kJGGzYgrWdP8+Mql7a4vijh2WE0IrZWspQj/TpTxDho/YxO+AnOZnIjQcCD+q6iJsw==} + + tldts@7.4.10: + resolution: {integrity: sha512-GgouD1B+sWwvkaEq8vXC15DjQitxbvs12oIXELpconwm+Tg3zfcEv4jgzq3vtKverDXsg3VI8aRgNL2Nra0Iog==} + hasBin: true tmp-promise@3.0.3: resolution: {integrity: sha512-RwM7MoPojPxsOBYnyd2hy0bxtIlVrihNs9pj5SUvY8Zz1sQcQG2tG1hSr8PDxfgEB8RNKDhqbIlroIarSNDNsQ==} @@ -5795,9 +6003,17 @@ packages: tomlify-j0.4@3.0.0: resolution: {integrity: sha512-2Ulkc8T7mXJ2l0W476YC/A209PR38Nw8PuaCNtk9uI3t1zzFdGQeWYGQvmj2PZkVvRC/Yoi4xQKMRnWc/N29tQ==} + tough-cookie@6.0.2: + resolution: {integrity: sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==} + engines: {node: '>=16'} + tr46@0.0.3: resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} + tr46@6.0.0: + resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==} + engines: {node: '>=20'} + triple-beam@1.4.1: resolution: {integrity: sha512-aZbgViZrg1QNcG+LULa7nhZpJTZSLm/mXnHXnbAbjmN5aSa0y7V+wvv6+4WaBtpISJzThKy+PIPxc1Nq1EJ9mg==} engines: {node: '>= 14.0.0'} @@ -5905,6 +6121,10 @@ packages: undici-types@8.3.0: resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==} + undici@8.10.0: + resolution: {integrity: sha512-HvltHd7avK13QIw/oLe4qoOLyoVSoafqJ2jYOrtMRBkbYT31eiBQ8O0ehRKZiEZCMEyLFQNIADpgCWC5fALvYQ==} + engines: {node: '>=22.19.0'} + unicorn-magic@0.1.0: resolution: {integrity: sha512-lRfVq8fE8gz6QMBuDM6a+LO3IAzTi05H6gCVaUpir2E1Rwpo4ZUog45KpNXKC/Mn3Yb9UDuHumeFTo9iV/D9FQ==} engines: {node: '>=18'} @@ -6087,23 +6307,23 @@ packages: yaml: optional: true - vitest@4.1.11: - resolution: {integrity: sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==} - engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + vitest@5.0.0-beta.7: + resolution: {integrity: sha512-YT/AWBSxJW/GHyNVQzVA7lIj39oRcEPEj1AYMi1Oko7l5GwCh8MO0ufavjVZI0oEYXcAoNmthegJhzGA2pkU2A==} + engines: {node: ^22.12.0 || ^24.0.0 || >=26.0.0} hasBin: true peerDependencies: '@edge-runtime/vm': '*' '@opentelemetry/api': ^1.9.0 - '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 - '@vitest/browser-playwright': 4.1.11 - '@vitest/browser-preview': 4.1.11 - '@vitest/browser-webdriverio': 4.1.11 - '@vitest/coverage-istanbul': 4.1.11 - '@vitest/coverage-v8': 4.1.11 - '@vitest/ui': 4.1.11 + '@types/node': ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 5.0.0-beta.7 + '@vitest/browser-preview': 5.0.0-beta.7 + '@vitest/browser-webdriverio': ^5.0.0-beta.5 || >=5.0.0 + '@vitest/coverage-istanbul': 5.0.0-beta.7 + '@vitest/coverage-v8': 5.0.0-beta.7 + '@vitest/ui': 5.0.0-beta.7 happy-dom: '*' jsdom: '*' - vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + vite: ^6.4.0 || ^7.0.0 || ^8.0.0 peerDependenciesMeta: '@edge-runtime/vm': optional: true @@ -6128,6 +6348,10 @@ packages: jsdom: optional: true + w3c-xmlserializer@5.0.0: + resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} + engines: {node: '>=18'} + waddler@0.1.1: resolution: {integrity: sha512-lBJXYFBLEpYe+scAeCJmLj6Iqweuq1whM6Am3I9WfopOCFxvKz8Nq5hXoy8/b3zwJqHIQMglFIvM4skRydSpZg==} peerDependencies: @@ -6203,6 +6427,22 @@ packages: webidl-conversions@3.0.1: resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} + webidl-conversions@8.0.1: + resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==} + engines: {node: '>=20'} + + whatwg-mimetype@5.0.0: + resolution: {integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==} + engines: {node: '>=20'} + + whatwg-url@16.0.1: + resolution: {integrity: sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + whatwg-url@17.1.0: + resolution: {integrity: sha512-3GeworPmc2ZfEEHP7lEbUfBX/L75wdEsi0rLNhXcXxnoN5jyq0SL5gCy06SGW2cyTIZdTvWIDQNQoza++vKeaw==} + engines: {node: ^22.14.0 || >=24.0.0} + whatwg-url@5.0.0: resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} @@ -6302,6 +6542,13 @@ packages: resolution: {integrity: sha512-GCPAHLvrIH13+c0SuacwvRYj2SxJXQ4kaVTT5xgL3kPrz56XxkF21IGhjSE1+W0aw7gpBWRGXLCPnPby6lSpmQ==} engines: {node: '>=12'} + 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==} + xss@1.0.15: resolution: {integrity: sha512-FVdlVVC67WOIPvfOwhoMETV72f6GbW7aOabBC3WxN/oUdoEMDyLz4OgRv5/gck2ZeNqEQu+Tb0kloovXOfpYVg==} engines: {node: '>= 0.10.0'} @@ -6377,6 +6624,23 @@ packages: snapshots: + '@adobe/css-tools@4.5.0': {} + + '@asamuzakjp/css-color@6.0.7': + dependencies: + '@csstools/css-calc': 3.3.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-color-parser': 4.2.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + lru-cache: 11.5.2 + + '@asamuzakjp/dom-selector@8.3.2': + dependencies: + bidi-js: 1.0.3 + css-tree: 3.2.1 + is-potential-custom-element-name: 1.0.1 + lru-cache: 11.5.2 + '@babel/code-frame@7.29.7': dependencies: '@babel/helper-validator-identifier': 7.29.7 @@ -6581,6 +6845,8 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/runtime@7.29.7': {} + '@babel/template@7.29.7': dependencies: '@babel/code-frame': 7.29.7 @@ -6663,6 +6929,10 @@ snapshots: '@better-fetch/fetch@1.3.1': {} + '@bramus/specificity@2.4.2': + dependencies: + css-tree: 3.2.1 + '@bugsnag/browser@8.10.0': dependencies: '@bugsnag/core': 8.9.0 @@ -6726,6 +6996,30 @@ snapshots: dependencies: '@jridgewell/trace-mapping': 0.3.9 + '@csstools/color-helpers@6.1.1': {} + + '@csstools/css-calc@3.3.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-color-parser@4.2.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/color-helpers': 6.1.1 + '@csstools/css-calc': 3.3.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.8(css-tree@3.2.1)': + optionalDependencies: + css-tree: 3.2.1 + + '@csstools/css-tokenizer@4.0.0': {} + '@dabh/diagnostics@2.0.8': dependencies: '@so-ric/colorspace': 1.1.6 @@ -6974,6 +7268,10 @@ snapshots: '@eslint/core': 0.17.0 levn: 0.4.1 + '@exodus/bytes@1.15.1(@noble/hashes@2.3.0)': + optionalDependencies: + '@noble/hashes': 2.3.0 + '@fastify/accept-negotiator@2.1.0': {} '@fastify/ajv-compiler@4.0.6': @@ -8142,6 +8440,43 @@ snapshots: dependencies: tslib: 2.8.1 + '@testing-library/dom@10.4.1': + dependencies: + '@babel/code-frame': 7.29.7 + '@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@7.0.1(@testing-library/dom@10.4.1)(vitest@5.0.0-beta.7(@opentelemetry/api@1.9.1)(@types/node@26.2.0)(jsdom@30.0.1(@noble/hashes@2.3.0))(vite@8.2.1(@types/node@26.2.0)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)))': + dependencies: + '@adobe/css-tools': 4.5.0 + '@testing-library/dom': 10.4.1 + aria-query: 5.3.2 + css.escape: 1.5.1 + dom-accessibility-api: 0.6.3 + picocolors: 1.1.1 + redent: 3.0.0 + optionalDependencies: + vitest: 5.0.0-beta.7(@opentelemetry/api@1.9.1)(@types/node@26.2.0)(jsdom@30.0.1(@noble/hashes@2.3.0))(vite@8.2.1(@types/node@26.2.0)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)) + + '@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@babel/runtime': 7.29.7 + '@testing-library/dom': 10.4.1 + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.18 + '@types/react-dom': 19.2.4(@types/react@19.2.18) + + '@testing-library/user-event@14.6.5(@testing-library/dom@10.4.1)': + dependencies: + '@testing-library/dom': 10.4.1 + '@tsconfig/node10@1.0.13': {} '@tsconfig/node12@1.0.11': {} @@ -8155,6 +8490,8 @@ snapshots: tslib: 2.8.1 optional: true + '@types/aria-query@5.0.4': {} + '@types/chai@5.2.3': dependencies: '@types/deep-eql': 4.0.2 @@ -8403,46 +8740,21 @@ snapshots: - rollup - supports-color - '@vitest/expect@4.1.11': + '@vitejs/plugin-react@6.0.5(vite@8.2.1(@types/node@26.2.0)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0))': dependencies: - '@standard-schema/spec': 1.1.0 - '@types/chai': 5.2.3 - '@vitest/spy': 4.1.11 - '@vitest/utils': 4.1.11 - chai: 6.2.2 - tinyrainbow: 3.1.1 + '@rolldown/pluginutils': 1.0.1 + vite: 8.2.1(@types/node@26.2.0)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0) - '@vitest/mocker@4.1.11(vite@8.2.1(@types/node@26.2.0)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0))': + '@vitest/mocker@5.0.0-beta.7(vite@8.2.1(@types/node@26.2.0)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0))': dependencies: - '@vitest/spy': 4.1.11 + '@jridgewell/trace-mapping': 0.3.31 + '@vitest/spy': 5.0.0-beta.7 estree-walker: 3.0.3 - magic-string: 0.30.21 + magic-string: 1.2.1 optionalDependencies: vite: 8.2.1(@types/node@26.2.0)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0) - '@vitest/pretty-format@4.1.11': - dependencies: - tinyrainbow: 3.1.1 - - '@vitest/runner@4.1.11': - dependencies: - '@vitest/utils': 4.1.11 - pathe: 2.0.3 - - '@vitest/snapshot@4.1.11': - dependencies: - '@vitest/pretty-format': 4.1.11 - '@vitest/utils': 4.1.11 - magic-string: 0.30.21 - pathe: 2.0.3 - - '@vitest/spy@4.1.11': {} - - '@vitest/utils@4.1.11': - dependencies: - '@vitest/pretty-format': 4.1.11 - convert-source-map: 2.0.0 - tinyrainbow: 3.1.1 + '@vitest/spy@5.0.0-beta.7': {} '@vue/compiler-core@3.5.41': dependencies: @@ -8580,6 +8892,8 @@ snapshots: dependencies: color-convert: 2.0.1 + ansi-styles@5.2.0: {} + ansi-styles@6.2.3: {} ansi-to-html@0.7.2: @@ -8613,6 +8927,10 @@ snapshots: argparse@2.0.1: {} + aria-query@5.3.0: + dependencies: + dequal: 2.0.3 + aria-query@5.3.2: {} array-buffer-byte-length@1.0.2: @@ -8705,7 +9023,7 @@ snapshots: stubborn-fs: 2.0.0 when-exit: 2.1.5 - auth@1.7.1(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.4.3))(chokidar@5.0.0)(drizzle-kit@1.0.0-rc.4)(drizzle-orm@1.0.0-rc.4(@neondatabase/serverless@1.1.0)(@opentelemetry/api@1.9.1)(pg@8.23.0)(zod@4.4.3))(jose@6.2.9)(kysely@0.29.5)(nanostores@1.5.1)(next@16.3.1(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@types/node@26.2.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(pg@8.23.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(vitest@4.1.11(@opentelemetry/api@1.9.1)(@types/node@26.2.0)(vite@8.2.1(@types/node@26.2.0)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0))): + auth@1.7.1(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.4.3))(chokidar@5.0.0)(drizzle-kit@1.0.0-rc.4)(drizzle-orm@1.0.0-rc.4(@neondatabase/serverless@1.1.0)(@opentelemetry/api@1.9.1)(pg@8.23.0)(zod@4.4.3))(jose@6.2.9)(kysely@0.29.5)(nanostores@1.5.1)(next@16.3.1(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@types/node@26.2.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(pg@8.23.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(vitest@5.0.0-beta.7(@opentelemetry/api@1.9.1)(@types/node@26.2.0)(jsdom@30.0.1(@noble/hashes@2.3.0))(vite@8.2.1(@types/node@26.2.0)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0))): dependencies: '@babel/core': 7.29.7 '@babel/preset-react': 7.29.7(@babel/core@7.29.7) @@ -8715,7 +9033,7 @@ snapshots: '@better-auth/utils': 0.4.2 '@clack/prompts': 1.7.0 '@mrleebo/prisma-ast': 0.16.0 - better-auth: 1.7.1(@opentelemetry/api@1.9.1)(drizzle-kit@1.0.0-rc.4)(drizzle-orm@1.0.0-rc.4(@neondatabase/serverless@1.1.0)(@opentelemetry/api@1.9.1)(pg@8.23.0)(zod@4.4.3))(next@16.3.1(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@types/node@26.2.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(pg@8.23.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(vitest@4.1.11(@opentelemetry/api@1.9.1)(@types/node@26.2.0)(vite@8.2.1(@types/node@26.2.0)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0))) + better-auth: 1.7.1(@opentelemetry/api@1.9.1)(drizzle-kit@1.0.0-rc.4)(drizzle-orm@1.0.0-rc.4(@neondatabase/serverless@1.1.0)(@opentelemetry/api@1.9.1)(pg@8.23.0)(zod@4.4.3))(next@16.3.1(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@types/node@26.2.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(pg@8.23.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(vitest@5.0.0-beta.7(@opentelemetry/api@1.9.1)(@types/node@26.2.0)(jsdom@30.0.1(@noble/hashes@2.3.0))(vite@8.2.1(@types/node@26.2.0)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0))) c12: 4.0.0-beta.5(chokidar@5.0.0)(dotenv@17.4.2)(jiti@2.7.0) chalk: 5.6.2 commander: 15.0.0 @@ -8827,7 +9145,7 @@ snapshots: jsonpointer: 5.0.1 leven: 3.1.0 - better-auth@1.7.1(@opentelemetry/api@1.9.1)(drizzle-kit@1.0.0-rc.4)(drizzle-orm@1.0.0-rc.4(@neondatabase/serverless@1.1.0)(@opentelemetry/api@1.9.1)(pg@8.23.0)(zod@4.4.3))(next@16.3.1(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@types/node@26.2.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(pg@8.23.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(vitest@4.1.11(@opentelemetry/api@1.9.1)(@types/node@26.2.0)(vite@8.2.1(@types/node@26.2.0)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0))): + better-auth@1.7.1(@opentelemetry/api@1.9.1)(drizzle-kit@1.0.0-rc.4)(drizzle-orm@1.0.0-rc.4(@neondatabase/serverless@1.1.0)(@opentelemetry/api@1.9.1)(pg@8.23.0)(zod@4.4.3))(next@16.3.1(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@types/node@26.2.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(pg@8.23.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(vitest@5.0.0-beta.7(@opentelemetry/api@1.9.1)(@types/node@26.2.0)(jsdom@30.0.1(@noble/hashes@2.3.0))(vite@8.2.1(@types/node@26.2.0)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0))): dependencies: '@better-auth/core': 1.7.1(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.4.3))(jose@6.2.9)(kysely@0.29.5)(nanostores@1.5.1) '@better-auth/drizzle-adapter': 1.7.1(@better-auth/core@1.7.1(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.4.3))(jose@6.2.9)(kysely@0.29.5)(nanostores@1.5.1))(@better-auth/utils@0.4.2)(drizzle-orm@1.0.0-rc.4(@neondatabase/serverless@1.1.0)(@opentelemetry/api@1.9.1)(pg@8.23.0)(zod@4.4.3)) @@ -8853,7 +9171,7 @@ snapshots: pg: 8.23.0 react: 19.2.8 react-dom: 19.2.8(react@19.2.8) - vitest: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@26.2.0)(vite@8.2.1(@types/node@26.2.0)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)) + vitest: 5.0.0-beta.7(@opentelemetry/api@1.9.1)(@types/node@26.2.0)(jsdom@30.0.1(@noble/hashes@2.3.0))(vite@8.2.1(@types/node@26.2.0)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)) transitivePeerDependencies: - '@cloudflare/workers-types' - '@opentelemetry/api' @@ -8867,6 +9185,10 @@ snapshots: optionalDependencies: zod: 4.4.3 + bidi-js@1.0.3: + dependencies: + require-from-string: 2.0.2 + bindings@1.5.0: dependencies: file-uri-to-path: 1.0.0 @@ -9211,6 +9533,8 @@ snapshots: css-what@6.2.2: {} + css.escape@1.5.1: {} + cssfilter@0.0.10: {} csso@5.0.5: @@ -9225,6 +9549,13 @@ snapshots: data-uri-to-buffer@4.0.1: {} + data-urls@7.0.0(@noble/hashes@2.3.0): + dependencies: + whatwg-mimetype: 5.0.0 + whatwg-url: 16.0.1(@noble/hashes@2.3.0) + transitivePeerDependencies: + - '@noble/hashes' + data-view-buffer@1.0.2: dependencies: call-bound: 1.0.4 @@ -9259,6 +9590,8 @@ snapshots: dependencies: callsite: 1.0.0 + decimal.js@10.6.0: {} + deep-extend@0.6.0: {} deep-is@0.1.4: {} @@ -9364,6 +9697,10 @@ snapshots: dependencies: esutils: 2.0.3 + dom-accessibility-api@0.5.16: {} + + dom-accessibility-api@0.6.3: {} + dom-serializer@2.0.0: dependencies: domelementtype: 2.3.0 @@ -9452,6 +9789,8 @@ snapshots: entities@7.0.1: {} + entities@8.0.0: {} + env-paths@3.0.0: {} env-paths@4.0.0: @@ -10355,6 +10694,12 @@ snapshots: optionalDependencies: unix-dgram: 2.0.7 + html-encoding-sniffer@6.0.0(@noble/hashes@2.3.0): + dependencies: + '@exodus/bytes': 1.15.1(@noble/hashes@2.3.0) + transitivePeerDependencies: + - '@noble/hashes' + http-errors@2.0.0: dependencies: depd: 2.0.0 @@ -10435,6 +10780,8 @@ snapshots: imurmurhash@0.1.4: {} + indent-string@4.0.0: {} + indent-string@5.0.0: {} index-to-position@1.2.0: {} @@ -10643,6 +10990,8 @@ snapshots: is-plain-object@5.0.0: {} + is-potential-custom-element-name@1.0.1: {} + is-promise@4.0.0: {} is-regex@1.2.1: @@ -10739,6 +11088,32 @@ snapshots: jsbi@4.3.2: {} + jsdom@30.0.1(@noble/hashes@2.3.0): + dependencies: + '@asamuzakjp/css-color': 6.0.7 + '@asamuzakjp/dom-selector': 8.3.2 + '@bramus/specificity': 2.4.2 + '@csstools/css-syntax-patches-for-csstree': 1.1.8(css-tree@3.2.1) + '@exodus/bytes': 1.15.1(@noble/hashes@2.3.0) + css-tree: 3.2.1 + data-urls: 7.0.0(@noble/hashes@2.3.0) + decimal.js: 10.6.0 + html-encoding-sniffer: 6.0.0(@noble/hashes@2.3.0) + is-potential-custom-element-name: 1.0.1 + lru-cache: 11.5.2 + parse5: 8.0.1 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 6.0.2 + undici: 8.10.0 + w3c-xmlserializer: 5.0.0 + webidl-conversions: 8.0.1 + whatwg-mimetype: 5.0.0 + whatwg-url: 17.1.0(@noble/hashes@2.3.0) + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - '@noble/hashes' + jsesc@3.1.0: {} json-buffer@3.0.1: {} @@ -10999,12 +11374,18 @@ snapshots: luxon@3.7.2: {} + lz-string@1.5.0: {} + macos-release@3.5.1: {} magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + magic-string@1.2.1: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + make-error@1.3.6: {} map-obj@5.0.2: {} @@ -11062,6 +11443,8 @@ snapshots: mimic-function@5.0.1: {} + min-indent@1.0.1: {} + minimatch@10.2.6: dependencies: brace-expansion: 5.0.9 @@ -11568,6 +11951,10 @@ snapshots: parse-ms@4.0.0: {} + parse5@8.0.1: + dependencies: + entities: 8.0.0 + parseurl@1.3.3: {} path-exists@4.0.0: {} @@ -11730,6 +12117,12 @@ snapshots: prettier@3.9.6: {} + pretty-format@27.5.1: + dependencies: + ansi-regex: 5.0.1 + ansi-styles: 5.2.0 + react-is: 17.0.2 + pretty-ms@9.3.0: dependencies: parse-ms: 4.0.0 @@ -11826,6 +12219,8 @@ snapshots: react-is@16.13.1: {} + react-is@17.0.2: {} + react@19.2.8: {} read-package-up@11.0.0: @@ -11892,6 +12287,11 @@ snapshots: real-require@1.0.0: {} + redent@3.0.0: + dependencies: + indent-string: 4.0.0 + strip-indent: 3.0.0 + reflect.getprototypeof@1.0.10: dependencies: call-bind: 1.0.9 @@ -12053,6 +12453,10 @@ snapshots: sax@1.6.1: {} + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 + scheduler@0.27.0: {} secure-json-parse@4.1.0: {} @@ -12399,6 +12803,10 @@ snapshots: strip-final-newline@3.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: {} @@ -12444,6 +12852,8 @@ snapshots: picocolors: 1.1.1 sax: 1.6.1 + symbol-tree@3.2.4: {} + tagged-tag@1.0.0: {} tar-stream@3.2.0: @@ -12501,7 +12911,7 @@ snapshots: through@2.3.8: {} - tinybench@2.9.0: {} + tinybench@6.1.3: {} tinyclip@0.1.15: {} @@ -12512,7 +12922,11 @@ snapshots: fdir: 6.5.0(picomatch@4.0.5) picomatch: 4.0.5 - tinyrainbow@3.1.1: {} + tldts-core@7.4.10: {} + + tldts@7.4.10: + dependencies: + tldts-core: 7.4.10 tmp-promise@3.0.3: dependencies: @@ -12532,8 +12946,16 @@ snapshots: tomlify-j0.4@3.0.0: {} + tough-cookie@6.0.2: + dependencies: + tldts: 7.4.10 + tr46@0.0.3: {} + tr46@6.0.0: + dependencies: + punycode: 2.3.1 + triple-beam@1.4.1: {} ts-api-utils@2.5.0(typescript@5.9.3): @@ -12658,6 +13080,8 @@ snapshots: undici-types@8.3.0: {} + undici@8.10.0: {} + unicorn-magic@0.1.0: {} unicorn-magic@0.3.0: {} @@ -12778,34 +13202,33 @@ snapshots: jiti: 2.7.0 yaml: 2.9.0 - vitest@4.1.11(@opentelemetry/api@1.9.1)(@types/node@26.2.0)(vite@8.2.1(@types/node@26.2.0)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)): + vitest@5.0.0-beta.7(@opentelemetry/api@1.9.1)(@types/node@26.2.0)(jsdom@30.0.1(@noble/hashes@2.3.0))(vite@8.2.1(@types/node@26.2.0)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)): dependencies: - '@vitest/expect': 4.1.11 - '@vitest/mocker': 4.1.11(vite@8.2.1(@types/node@26.2.0)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)) - '@vitest/pretty-format': 4.1.11 - '@vitest/runner': 4.1.11 - '@vitest/snapshot': 4.1.11 - '@vitest/spy': 4.1.11 - '@vitest/utils': 4.1.11 + '@types/chai': 5.2.3 + '@vitest/mocker': 5.0.0-beta.7(vite@8.2.1(@types/node@26.2.0)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)) + chai: 6.2.2 es-module-lexer: 2.3.2 expect-type: 1.4.0 - magic-string: 0.30.21 + magic-string: 1.2.1 obug: 2.1.4 - pathe: 2.0.3 picomatch: 4.0.5 std-env: 4.2.0 - tinybench: 2.9.0 + tinybench: 6.1.3 tinyexec: 1.3.0 tinyglobby: 0.2.17 - tinyrainbow: 3.1.1 vite: 8.2.1(@types/node@26.2.0)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0) why-is-node-running: 2.3.0 optionalDependencies: '@opentelemetry/api': 1.9.1 '@types/node': 26.2.0 + jsdom: 30.0.1(@noble/hashes@2.3.0) transitivePeerDependencies: - msw + w3c-xmlserializer@5.0.0: + dependencies: + xml-name-validator: 5.0.0 + waddler@0.1.1(@neondatabase/serverless@1.1.0)(pg@8.23.0): optionalDependencies: '@neondatabase/serverless': 1.1.0 @@ -12819,6 +13242,26 @@ snapshots: webidl-conversions@3.0.1: {} + webidl-conversions@8.0.1: {} + + whatwg-mimetype@5.0.0: {} + + whatwg-url@16.0.1(@noble/hashes@2.3.0): + dependencies: + '@exodus/bytes': 1.15.1(@noble/hashes@2.3.0) + tr46: 6.0.0 + webidl-conversions: 8.0.1 + transitivePeerDependencies: + - '@noble/hashes' + + whatwg-url@17.1.0(@noble/hashes@2.3.0): + dependencies: + '@exodus/bytes': 1.15.1(@noble/hashes@2.3.0) + tr46: 6.0.0 + webidl-conversions: 8.0.1 + transitivePeerDependencies: + - '@noble/hashes' + whatwg-url@5.0.0: dependencies: tr46: 0.0.3 @@ -12951,6 +13394,10 @@ snapshots: xdg-basedir@5.1.0: {} + xml-name-validator@5.0.0: {} + + xmlchars@2.2.0: {} + xss@1.0.15: dependencies: commander: 2.20.3 diff --git a/vitest.config.mts b/vitest.config.mts index 7bf173f..b3eb690 100644 --- a/vitest.config.mts +++ b/vitest.config.mts @@ -1,10 +1,15 @@ -import { fileURLToPath } from "node:url"; +import react from "@vitejs/plugin-react"; import { defineConfig } from "vitest/config"; export default defineConfig({ + plugins: [react()], resolve: { - alias: { - "@": fileURLToPath(new URL(".", import.meta.url)), - }, + tsconfigPaths: true, + }, + test: { + environment: "node", + setupFiles: ["./vitest.setup.ts"], + clearMocks: true, + restoreMocks: true, }, }); diff --git a/vitest.setup.ts b/vitest.setup.ts new file mode 100644 index 0000000..1efae31 --- /dev/null +++ b/vitest.setup.ts @@ -0,0 +1,7 @@ +import "@testing-library/jest-dom/vitest"; +import { cleanup } from "@testing-library/react"; +import { afterEach } from "vitest"; + +afterEach(() => { + cleanup(); +});