From 487902421b7193a65397c90baa9f51a68140b0ed Mon Sep 17 00:00:00 2001 From: KM Koushik Date: Mon, 16 Feb 2026 09:13:29 +1100 Subject: [PATCH] feat: add web testing foundation with infra-backed suites (#349) * feat: add web test framework with infra-backed suites * fix: honor DATABASE_URL env in integration prepare script * fix: apply web test review feedback * fix: streamline web test infra lifecycle and workflow scope --- .github/workflows/test-web.yml | 86 ++++ AGENTS.md | 8 +- apps/web/.env.test.example | 14 + apps/web/TESTING.md | 65 +++ apps/web/package.json | 16 +- apps/web/src/app/api/health/route.api.test.ts | 12 + .../app/api/webhook/stripe/route.api.test.ts | 55 ++ apps/web/src/lib/usage.ts | 4 +- apps/web/src/lib/usage.unit.test.ts | 35 ++ .../src/server/api/trpc.integration.test.ts | 125 +++++ apps/web/src/server/api/trpc.trpc.test.ts | 134 +++++ apps/web/src/server/jobs/usage-job.ts | 12 +- .../src/server/public-api/hono.api.test.ts | 109 ++++ .../public-api/hono.integration.test.ts | 95 ++++ apps/web/src/server/redis.ts | 4 +- .../service/api-service.integration.test.ts | 53 ++ .../idempotency-service.integration.test.ts | 78 +++ .../server/utils/email-content.unit.test.ts | 18 + .../server/utils/email-headers.unit.test.ts | 40 ++ .../src/server/utils/idempotency.unit.test.ts | 30 ++ apps/web/src/test/factories/core.ts | 54 ++ apps/web/src/test/integration/helpers.ts | 37 ++ apps/web/src/test/setup/setup-env.ts | 19 + apps/web/src/test/setup/setup-tests.ts | 5 + apps/web/vitest.api.config.ts | 11 + apps/web/vitest.config.ts | 28 + apps/web/vitest.default.config.ts | 12 + apps/web/vitest.integration.config.ts | 17 + apps/web/vitest.trpc.config.ts | 11 + apps/web/vitest.unit.config.ts | 11 + docker/testing/compose.yml | 29 ++ package.json | 10 + pnpm-lock.yaml | 478 +++++++++++++++++- 33 files changed, 1676 insertions(+), 39 deletions(-) create mode 100644 .github/workflows/test-web.yml create mode 100644 apps/web/.env.test.example create mode 100644 apps/web/TESTING.md create mode 100644 apps/web/src/app/api/health/route.api.test.ts create mode 100644 apps/web/src/app/api/webhook/stripe/route.api.test.ts create mode 100644 apps/web/src/lib/usage.unit.test.ts create mode 100644 apps/web/src/server/api/trpc.integration.test.ts create mode 100644 apps/web/src/server/api/trpc.trpc.test.ts create mode 100644 apps/web/src/server/public-api/hono.api.test.ts create mode 100644 apps/web/src/server/public-api/hono.integration.test.ts create mode 100644 apps/web/src/server/service/api-service.integration.test.ts create mode 100644 apps/web/src/server/service/idempotency-service.integration.test.ts create mode 100644 apps/web/src/server/utils/email-content.unit.test.ts create mode 100644 apps/web/src/server/utils/email-headers.unit.test.ts create mode 100644 apps/web/src/server/utils/idempotency.unit.test.ts create mode 100644 apps/web/src/test/factories/core.ts create mode 100644 apps/web/src/test/integration/helpers.ts create mode 100644 apps/web/src/test/setup/setup-env.ts create mode 100644 apps/web/src/test/setup/setup-tests.ts create mode 100644 apps/web/vitest.api.config.ts create mode 100644 apps/web/vitest.config.ts create mode 100644 apps/web/vitest.default.config.ts create mode 100644 apps/web/vitest.integration.config.ts create mode 100644 apps/web/vitest.trpc.config.ts create mode 100644 apps/web/vitest.unit.config.ts create mode 100644 docker/testing/compose.yml diff --git a/.github/workflows/test-web.yml b/.github/workflows/test-web.yml new file mode 100644 index 0000000..d5139ed --- /dev/null +++ b/.github/workflows/test-web.yml @@ -0,0 +1,86 @@ +name: Web Tests + +on: + pull_request: + paths: + - "apps/web/**" + push: + branches: + - main + paths: + - "apps/web/**" + +jobs: + web-tests: + runs-on: ubuntu-latest + env: + NODE_ENV: test + NEXTAUTH_URL: http://localhost:3000 + NEXTAUTH_SECRET: test-secret + DATABASE_URL: postgresql://usesend:password@127.0.0.1:5432/usesend_test + REDIS_URL: redis://127.0.0.1:6379/15 + AWS_ACCESS_KEY: test-access-key + AWS_SECRET_KEY: test-secret-key + AWS_DEFAULT_REGION: us-east-1 + NEXT_PUBLIC_IS_CLOUD: "true" + API_RATE_LIMIT: "2" + AUTH_EMAIL_RATE_LIMIT: "5" + RUN_INTEGRATION: "true" + services: + postgres: + image: postgres:16 + env: + POSTGRES_USER: usesend + POSTGRES_PASSWORD: password + POSTGRES_DB: usesend_test + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U usesend -d usesend_test" + --health-interval 5s + --health-timeout 5s + --health-retries 20 + redis: + image: redis:7 + ports: + - 6379:6379 + options: >- + --health-cmd "redis-cli ping" + --health-interval 5s + --health-timeout 5s + --health-retries 20 + + steps: + - uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 8.9.0 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Generate Prisma client + run: pnpm --filter=web db:generate + + - name: Prepare test database schema + run: pnpm --filter=web test:integration:prepare + + - name: Run unit tests + run: pnpm --filter=web test:unit + + - name: Run tRPC tests + run: pnpm --filter=web test:trpc + + - name: Run API tests + run: pnpm --filter=web test:api + + - name: Run integration tests + run: pnpm --filter=web test:integration diff --git a/AGENTS.md b/AGENTS.md index a5e8198..3394a29 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -36,7 +36,13 @@ ## Testing Guidelines -- No repo-wide test runner is configured yet. do not add any tests unless required +- Web testing is configured with Vitest in `apps/web`; add tests when changes impact logic, APIs, or behavior. +- Prefer targeted suites first: `pnpm test:web:unit`, `pnpm test:web:trpc`, `pnpm test:web:api`; use `pnpm test:web` for default non-integration coverage. +- Test file conventions: `*.unit.test.ts`, `*.trpc.test.ts`, `*.api.test.ts`, `*.integration.test.ts`. +- Integration tests require infra and env (`RUN_INTEGRATION=true` with Postgres/Redis available). Root commands `pnpm test:web:all` and `pnpm test:web:integration:full` auto-manage infra lifecycle. +- Use `pnpm test:infra:up` / `pnpm test:infra:down` when running targeted integration commands manually. +- `pnpm test:web:integration:full` and `test:integration:prepare` run Prisma migrations (`prisma migrate deploy`); never run these unless the user explicitly asks. +- Test defaults are cloud mode (`NEXT_PUBLIC_IS_CLOUD=true`); keep new tests compatible with cloud behavior unless the task says otherwise. ## Commit & Pull Request Guidelines diff --git a/apps/web/.env.test.example b/apps/web/.env.test.example new file mode 100644 index 0000000..1f62dec --- /dev/null +++ b/apps/web/.env.test.example @@ -0,0 +1,14 @@ +NODE_ENV=test +NEXTAUTH_URL=http://localhost:3000 +NEXTAUTH_SECRET=test-secret + +DATABASE_URL=postgresql://usesend:password@127.0.0.1:54329/usesend_test +REDIS_URL=redis://127.0.0.1:6380/15 + +AWS_ACCESS_KEY=test-access-key +AWS_SECRET_KEY=test-secret-key +AWS_DEFAULT_REGION=us-east-1 + +NEXT_PUBLIC_IS_CLOUD=true +API_RATE_LIMIT=2 +AUTH_EMAIL_RATE_LIMIT=5 diff --git a/apps/web/TESTING.md b/apps/web/TESTING.md new file mode 100644 index 0000000..08b67ea --- /dev/null +++ b/apps/web/TESTING.md @@ -0,0 +1,65 @@ +# Testing in `apps/web` + +This app now supports four testing layers: + +- Unit tests (`*.unit.test.ts`) +- tRPC tests (`*.trpc.test.ts`) +- API tests (`*.api.test.ts`) +- Infra-backed integration tests (`*.integration.test.ts`) + +## Stack + +- Runner: Vitest +- Coverage: V8 provider via `@vitest/coverage-v8` +- Path aliases: `vite-tsconfig-paths` +- Infra for integration: PostgreSQL + Redis via Docker Compose + +## Commands + +From repo root: + +- `pnpm test:web` +- `pnpm test:web:all` +- `pnpm test:web:unit` +- `pnpm test:web:trpc` +- `pnpm test:web:api` +- `pnpm test:web:integration` +- `pnpm test:web:integration:full` + +Infra helpers: + +- `pnpm test:infra:up` +- `pnpm test:infra:down` + +Full integration flow: + +1. `pnpm test:infra:up` +2. `pnpm test:web:integration:full` (or `pnpm test:web:all`) +3. `pnpm test:infra:down` + +## Infra configuration + +- Compose file: `docker/testing/compose.yml` +- Postgres: `127.0.0.1:54329` (`usesend_test`) +- Redis: `127.0.0.1:6380` (test DB index `15`) + +The default test env is bootstrapped in `src/test/setup/setup-env.ts`. +Override values by exporting env vars before running tests. + +## Test layout + +- `src/test/setup/*`: global test bootstrap +- `src/test/integration/*`: integration reset helpers +- Tests colocated next to modules under `src/**` + +## Notes + +- Integration suites only run when `RUN_INTEGRATION=true`. +- Integration helpers truncate all public Postgres tables (except `_prisma_migrations`) and flush Redis DB before each test. +- Queue and Redis tests rely on `REDIS_URL` test DB index to avoid polluting local dev state. + +## CI + +GitHub Actions workflow: `.github/workflows/test-web.yml` + +The workflow runs unit, tRPC, API, and integration tests with PostgreSQL and Redis services. diff --git a/apps/web/package.json b/apps/web/package.json index 20f6f54..c823db1 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -8,6 +8,17 @@ "build": "next build", "start": "next start", "lint": "eslint . --max-warnings 0", + "test": "vitest run -c vitest.default.config.ts", + "test:watch": "vitest -c vitest.default.config.ts", + "test:all": "pnpm test:unit && pnpm test:trpc && pnpm test:api && pnpm test:integration:full", + "test:unit": "vitest run -c vitest.unit.config.ts", + "test:trpc": "vitest run -c vitest.trpc.config.ts", + "test:api": "vitest run -c vitest.api.config.ts", + "test:integration:prepare": "prisma migrate deploy", + "test:integration:prepare:local": "DATABASE_URL=postgresql://usesend:password@127.0.0.1:54329/usesend_test pnpm test:integration:prepare", + "test:integration": "RUN_INTEGRATION=true vitest run -c vitest.integration.config.ts", + "test:integration:full": "pnpm test:integration:prepare:local && pnpm test:integration", + "test:coverage": "vitest run -c vitest.default.config.ts --coverage", "db:post-install": "prisma generate", "db:generate": "prisma generate", "db:push": "prisma db push --skip-generate", @@ -97,7 +108,10 @@ "prettier": "^3.5.3", "prettier-plugin-tailwindcss": "^0.6.11", "tailwindcss": "^3.4.1", - "typescript": "^5.8.3" + "typescript": "^5.8.3", + "vite-tsconfig-paths": "^5.1.4", + "vitest": "^3.2.4", + "@vitest/coverage-v8": "^3.2.4" }, "overrides": { "react-is": "^19.0.0-rc-69d4b800-20241021" diff --git a/apps/web/src/app/api/health/route.api.test.ts b/apps/web/src/app/api/health/route.api.test.ts new file mode 100644 index 0000000..393d5f2 --- /dev/null +++ b/apps/web/src/app/api/health/route.api.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, it } from "vitest"; +import { GET } from "~/app/api/health/route"; + +describe("health route", () => { + it("returns healthy response", async () => { + const response = await GET(); + const body = await response.json(); + + expect(response.status).toBe(200); + expect(body).toEqual({ data: "Healthy" }); + }); +}); diff --git a/apps/web/src/app/api/webhook/stripe/route.api.test.ts b/apps/web/src/app/api/webhook/stripe/route.api.test.ts new file mode 100644 index 0000000..1b792d8 --- /dev/null +++ b/apps/web/src/app/api/webhook/stripe/route.api.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it, vi } from "vitest"; + +const { state } = vi.hoisted(() => ({ + state: { + signature: null as string | null, + }, +})); + +vi.mock("next/headers", () => ({ + headers: vi.fn(async () => { + const headers = new Headers(); + if (state.signature) { + headers.set("Stripe-Signature", state.signature); + } + return headers; + }), +})); + +vi.mock("~/server/billing/payments", () => ({ + getStripe: vi.fn(() => ({ + webhooks: { + constructEvent: vi.fn(), + }, + })), + syncStripeData: vi.fn(), +})); + +import { POST } from "~/app/api/webhook/stripe/route"; + +describe("stripe webhook route", () => { + it("returns 400 when signature header is missing", async () => { + state.signature = null; + + const response = await POST( + new Request("http://localhost", { method: "POST" }), + ); + + expect(response.status).toBe(400); + await expect(response.text()).resolves.toBe("No signature"); + }); + + it("returns 400 when webhook secret is not configured", async () => { + state.signature = "test-signature"; + + const response = await POST( + new Request("http://localhost", { + method: "POST", + body: "{}", + }), + ); + + expect(response.status).toBe(400); + await expect(response.text()).resolves.toBe("No webhook secret"); + }); +}); diff --git a/apps/web/src/lib/usage.ts b/apps/web/src/lib/usage.ts index c93f86e..9be6561 100644 --- a/apps/web/src/lib/usage.ts +++ b/apps/web/src/lib/usage.ts @@ -54,9 +54,9 @@ export function getUsageDate(): string { * @param transactionUsage Number of transaction emails sent * @returns Total usage units rounded down to nearest integer */ -export function getUsageUinits( +export function getUsageUnits( marketingUsage: number, - transactionUsage: number + transactionUsage: number, ) { return ( marketingUsage + diff --git a/apps/web/src/lib/usage.unit.test.ts b/apps/web/src/lib/usage.unit.test.ts new file mode 100644 index 0000000..4aacfd0 --- /dev/null +++ b/apps/web/src/lib/usage.unit.test.ts @@ -0,0 +1,35 @@ +import { EmailUsageType } from "@prisma/client"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + getCost, + getUsageDate, + getUsageTimestamp, + getUsageUnits, + TRANSACTIONAL_UNIT_CONVERSION, +} from "~/lib/usage"; + +describe("usage helpers", () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it("returns yesterday date and timestamp", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-02-08T12:00:00.000Z")); + + expect(getUsageDate()).toBe("2026-02-07"); + expect(getUsageTimestamp()).toBe( + Math.floor(new Date("2026-02-07T12:00:00.000Z").getTime() / 1000), + ); + }); + + it("converts transactional usage into billing units", () => { + const units = getUsageUnits(100, 40); + expect(units).toBe(100 + Math.floor(40 / TRANSACTIONAL_UNIT_CONVERSION)); + }); + + it("calculates cost per email type", () => { + expect(getCost(10, EmailUsageType.MARKETING)).toBe(0.01); + expect(getCost(4, EmailUsageType.TRANSACTIONAL)).toBe(0.001); + }); +}); diff --git a/apps/web/src/server/api/trpc.integration.test.ts b/apps/web/src/server/api/trpc.integration.test.ts new file mode 100644 index 0000000..40c3ab2 --- /dev/null +++ b/apps/web/src/server/api/trpc.integration.test.ts @@ -0,0 +1,125 @@ +import { TRPCError } from "@trpc/server"; +import { afterAll, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("~/server/auth", () => ({ + getServerAuthSession: vi.fn(), +})); + +import { + createCallerFactory, + createTRPCRouter, + protectedProcedure, + teamProcedure, +} from "~/server/api/trpc"; +import { Role } from "@prisma/client"; +import { + closeIntegrationConnections, + integrationEnabled, + resetDatabase, + resetRedis, +} from "~/test/integration/helpers"; +import { createTeamWithUser, createUser } from "~/test/factories/core"; + +const describeIntegration = integrationEnabled ? describe : describe.skip; + +const testRouter = createTRPCRouter({ + protectedPing: protectedProcedure.query(({ ctx }) => ({ + userId: ctx.session.user.id, + })), + teamPing: teamProcedure.query(({ ctx }) => ({ + teamId: ctx.team.id, + role: ctx.teamUser.role, + })), +}); + +const createCaller = createCallerFactory(testRouter); + +function createContext(user: { + id: number; + email: string; + isWaitlisted: boolean; + isAdmin: boolean; + isBetaUser: boolean; +}) { + return { + headers: new Headers(), + session: { + user, + }, + } as any; +} + +describeIntegration("tRPC integration", () => { + beforeEach(async () => { + await resetDatabase(); + await resetRedis(); + }); + + afterAll(async () => { + await closeIntegrationConnections(); + }); + + it("runs protected procedure with persisted user context", async () => { + const user = await createUser({ + email: "protected@example.com", + isBetaUser: true, + isWaitlisted: false, + }); + + const caller = createCaller( + createContext({ + id: user.id, + email: user.email as string, + isWaitlisted: false, + isAdmin: false, + isBetaUser: true, + }), + ); + + await expect(caller.protectedPing()).resolves.toEqual({ userId: user.id }); + }); + + it("resolves team procedure from postgres team membership", async () => { + const { user, team } = await createTeamWithUser(Role.ADMIN); + + const caller = createCaller( + createContext({ + id: user.id, + email: user.email as string, + isWaitlisted: false, + isAdmin: false, + isBetaUser: true, + }), + ); + + await expect(caller.teamPing()).resolves.toEqual({ + teamId: team.id, + role: "ADMIN", + }); + }); + + it("fails team procedure when user has no team", async () => { + const user = await createUser({ + email: "no-team@example.com", + isBetaUser: true, + isWaitlisted: false, + }); + + const caller = createCaller( + createContext({ + id: user.id, + email: user.email as string, + isWaitlisted: false, + isAdmin: false, + isBetaUser: true, + }), + ); + + const teamPingPromise = caller.teamPing(); + + await expect(teamPingPromise).rejects.toBeInstanceOf(TRPCError); + await expect(teamPingPromise).rejects.toMatchObject({ + code: "NOT_FOUND", + }); + }); +}); diff --git a/apps/web/src/server/api/trpc.trpc.test.ts b/apps/web/src/server/api/trpc.trpc.test.ts new file mode 100644 index 0000000..c1fdb40 --- /dev/null +++ b/apps/web/src/server/api/trpc.trpc.test.ts @@ -0,0 +1,134 @@ +import { TRPCError } from "@trpc/server"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { mockDb } = vi.hoisted(() => ({ + mockDb: { + teamUser: { + findFirst: vi.fn(), + }, + }, +})); + +vi.mock("~/server/db", () => ({ + db: mockDb, +})); + +vi.mock("~/server/auth", () => ({ + getServerAuthSession: vi.fn(), +})); + +import { + authedProcedure, + createCallerFactory, + createTRPCRouter, + protectedProcedure, + teamAdminProcedure, + teamProcedure, +} from "~/server/api/trpc"; + +const testRouter = createTRPCRouter({ + authedPing: authedProcedure.query(({ ctx }) => ({ + userId: ctx.session.user.id, + })), + protectedPing: protectedProcedure.query(({ ctx }) => ({ + userId: ctx.session.user.id, + })), + teamPing: teamProcedure.query(({ ctx }) => ({ teamId: ctx.team.id })), + teamAdminPing: teamAdminProcedure.query(({ ctx }) => ({ + role: ctx.teamUser.role, + })), +}); + +const createCaller = createCallerFactory(testRouter); + +function getContext(session: Record | null) { + return { + db: mockDb, + session, + headers: new Headers(), + } as any; +} + +const baseUser = { + id: 1, + isBetaUser: true, + isAdmin: false, + isWaitlisted: false, + email: "user@example.com", +}; + +describe("tRPC middleware procedures", () => { + beforeEach(() => { + mockDb.teamUser.findFirst.mockReset(); + }); + + it("blocks authed procedure without session", async () => { + const caller = createCaller(getContext(null)); + await expect(caller.authedPing()).rejects.toBeInstanceOf(TRPCError); + await expect(caller.authedPing()).rejects.toMatchObject({ + code: "UNAUTHORIZED", + }); + }); + + it("blocks protected procedure for waitlisted users", async () => { + const caller = createCaller( + getContext({ + user: { ...baseUser, isWaitlisted: true }, + }), + ); + + await expect(caller.protectedPing()).rejects.toMatchObject({ + code: "UNAUTHORIZED", + }); + }); + + it("loads team context for team procedure", async () => { + mockDb.teamUser.findFirst.mockResolvedValue({ + teamId: 10, + userId: 1, + role: "ADMIN", + team: { id: 10, name: "Acme" }, + }); + + const caller = createCaller( + getContext({ + user: baseUser, + }), + ); + + await expect(caller.teamPing()).resolves.toEqual({ teamId: 10 }); + }); + + it("blocks team admin procedure for non-admin team users", async () => { + mockDb.teamUser.findFirst.mockResolvedValue({ + teamId: 10, + userId: 1, + role: "MEMBER", + team: { id: 10, name: "Acme" }, + }); + + const caller = createCaller( + getContext({ + user: baseUser, + }), + ); + + await expect(caller.teamAdminPing()).rejects.toMatchObject({ + code: "UNAUTHORIZED", + }); + }); + + it("fails team procedure when user has no team", async () => { + mockDb.teamUser.findFirst.mockResolvedValue(null); + + const caller = createCaller( + getContext({ + user: baseUser, + }), + ); + + await expect(caller.teamPing()).rejects.toMatchObject({ + code: "NOT_FOUND", + }); + }); +}); diff --git a/apps/web/src/server/jobs/usage-job.ts b/apps/web/src/server/jobs/usage-job.ts index 7d8be25..140089f 100644 --- a/apps/web/src/server/jobs/usage-job.ts +++ b/apps/web/src/server/jobs/usage-job.ts @@ -1,7 +1,7 @@ import { Queue, Worker } from "bullmq"; import { db } from "~/server/db"; import { env } from "~/env"; -import { getUsageDate, getUsageUinits } from "~/lib/usage"; +import { getUsageDate, getUsageUnits } from "~/lib/usage"; import { sendUsageToStripe } from "~/server/billing/usage"; import { getRedis } from "~/server/redis"; import { DEFAULT_QUEUE_OPTIONS } from "../queue/queue-constants"; @@ -47,13 +47,13 @@ const worker = new Worker( .filter((usage) => usage.type === "MARKETING") .reduce((sum, usage) => sum + usage.sent, 0); - const totalUsage = getUsageUinits(marketingUsage, transactionUsage); + const totalUsage = getUsageUnits(marketingUsage, transactionUsage); try { await sendUsageToStripe(team.stripeCustomerId, totalUsage); logger.info( { teamId: team.id, date: getUsageDate(), usage: totalUsage }, - `[Usage Reporting] Reported usage for team` + `[Usage Reporting] Reported usage for team`, ); } catch (error) { logger.error( @@ -62,14 +62,14 @@ const worker = new Worker( teamId: team.id, message: error instanceof Error ? error.message : error, }, - `[Usage Reporting] Failed to report usage for team` + `[Usage Reporting] Failed to report usage for team`, ); } } }, { connection: getRedis(), - } + }, ); // Schedule job to run daily @@ -83,7 +83,7 @@ await usageQueue.upsertJobScheduler( opts: { ...DEFAULT_QUEUE_OPTIONS, }, - } + }, ); worker.on("completed", (job) => { diff --git a/apps/web/src/server/public-api/hono.api.test.ts b/apps/web/src/server/public-api/hono.api.test.ts new file mode 100644 index 0000000..4c22220 --- /dev/null +++ b/apps/web/src/server/public-api/hono.api.test.ts @@ -0,0 +1,109 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { UnsendApiError } from "~/server/public-api/api-error"; + +const { mockGetTeamFromToken, mockRedis } = vi.hoisted(() => ({ + mockGetTeamFromToken: vi.fn(), + mockRedis: { + incr: vi.fn(), + expire: vi.fn(), + ttl: vi.fn(), + }, +})); + +vi.mock("~/server/public-api/auth", () => ({ + getTeamFromToken: mockGetTeamFromToken, +})); + +vi.mock("~/server/redis", () => ({ + getRedis: () => mockRedis, +})); + +vi.mock("~/utils/common", () => ({ + isSelfHosted: () => false, +})); + +import { getApp } from "~/server/public-api/hono"; + +describe("public API Hono middleware", () => { + beforeEach(() => { + mockGetTeamFromToken.mockReset(); + mockRedis.incr.mockReset(); + mockRedis.expire.mockReset(); + mockRedis.ttl.mockReset(); + }); + + it("applies auth and rate limit headers", async () => { + mockGetTeamFromToken.mockResolvedValue({ + id: 1, + apiRateLimit: 2, + apiKeyId: 11, + apiKey: { domainId: null }, + }); + mockRedis.incr.mockResolvedValue(1); + mockRedis.expire.mockResolvedValue(1); + mockRedis.ttl.mockResolvedValue(1); + + const app = getApp(); + app.get("/v1/ping", (c) => c.json({ ok: true })); + + const response = await app.request("http://localhost/api/v1/ping", { + headers: { + Authorization: "Bearer test-key", + }, + }); + + expect(response.status).toBe(200); + expect(response.headers.get("X-RateLimit-Limit")).toBe("2"); + expect(response.headers.get("X-RateLimit-Remaining")).toBe("1"); + }); + + it("returns 429 when limit is exceeded", async () => { + mockGetTeamFromToken.mockResolvedValue({ + id: 1, + apiRateLimit: 2, + apiKeyId: 11, + apiKey: { domainId: null }, + }); + mockRedis.incr.mockResolvedValue(3); + mockRedis.ttl.mockResolvedValue(1); + + const app = getApp(); + app.get("/v1/ping", (c) => c.json({ ok: true })); + + const response = await app.request("http://localhost/api/v1/ping", { + headers: { + Authorization: "Bearer test-key", + }, + }); + + expect(response.status).toBe(429); + const body = await response.json(); + expect(body).toMatchObject({ + error: { + code: "RATE_LIMITED", + }, + }); + }); + + it("returns auth error from middleware", async () => { + mockGetTeamFromToken.mockRejectedValue( + new UnsendApiError({ + code: "UNAUTHORIZED", + message: "No Authorization header provided", + }), + ); + + const app = getApp(); + app.get("/v1/ping", (c) => c.json({ ok: true })); + + const response = await app.request("http://localhost/api/v1/ping"); + + expect(response.status).toBe(401); + const body = await response.json(); + expect(body).toMatchObject({ + error: { + code: "UNAUTHORIZED", + }, + }); + }); +}); diff --git a/apps/web/src/server/public-api/hono.integration.test.ts b/apps/web/src/server/public-api/hono.integration.test.ts new file mode 100644 index 0000000..2fcefae --- /dev/null +++ b/apps/web/src/server/public-api/hono.integration.test.ts @@ -0,0 +1,95 @@ +import { ApiPermission } from "@prisma/client"; +import { afterAll, beforeEach, describe, expect, it } from "vitest"; +import { getApp } from "~/server/public-api/hono"; +import { addApiKey } from "~/server/service/api-service"; +import { createTeam } from "~/test/factories/core"; +import { + closeIntegrationConnections, + integrationEnabled, + resetDatabase, + resetRedis, +} from "~/test/integration/helpers"; + +const describeIntegration = integrationEnabled ? describe : describe.skip; + +describeIntegration("Hono public API integration", () => { + beforeEach(async () => { + await resetDatabase(); + await resetRedis(); + }); + + afterAll(async () => { + await closeIntegrationConnections(); + }); + + it("authenticates request with persisted API key", async () => { + const team = await createTeam({ + name: "Auth Team", + apiRateLimit: 2, + }); + const apiKey = await addApiKey({ + name: "integration-key", + permission: ApiPermission.FULL, + teamId: team.id, + }); + + const app = getApp(); + app.get("/v1/ping", (c) => c.json({ teamId: c.var.team.id })); + + const response = await app.request("http://localhost/api/v1/ping", { + headers: { + Authorization: `Bearer ${apiKey}`, + }, + }); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toEqual({ teamId: team.id }); + }); + + it("returns forbidden when API key is invalid", async () => { + const app = getApp(); + app.get("/v1/ping", (c) => c.json({ ok: true })); + + const response = await app.request("http://localhost/api/v1/ping", { + headers: { + Authorization: "Bearer us_bad_token", + }, + }); + + expect(response.status).toBe(403); + await expect(response.json()).resolves.toMatchObject({ + error: { + code: "FORBIDDEN", + }, + }); + }); + + it("enforces Redis rate limits when cloud mode is enabled", async () => { + const team = await createTeam({ + name: "Rate Team", + apiRateLimit: 1, + }); + const apiKey = await addApiKey({ + name: "rate-key", + permission: ApiPermission.FULL, + teamId: team.id, + }); + + const app = getApp(); + app.get("/v1/ping", (c) => c.json({ ok: true })); + + const first = await app.request("http://localhost/api/v1/ping", { + headers: { + Authorization: `Bearer ${apiKey}`, + }, + }); + const second = await app.request("http://localhost/api/v1/ping", { + headers: { + Authorization: `Bearer ${apiKey}`, + }, + }); + + expect(first.status).toBe(200); + expect(second.status).toBe(429); + }); +}); diff --git a/apps/web/src/server/redis.ts b/apps/web/src/server/redis.ts index 9813a89..43b2368 100644 --- a/apps/web/src/server/redis.ts +++ b/apps/web/src/server/redis.ts @@ -4,7 +4,7 @@ import { env } from "~/env"; export let connection: IORedis | null = null; export const getRedis = () => { - if (!connection) { + if (!connection || connection.status === "end") { connection = new IORedis(`${env.REDIS_URL}?family=0`, { maxRetriesPerRequest: null, }); @@ -19,7 +19,7 @@ export const getRedis = () => { export async function withCache( key: string, fetcher: () => Promise, - options?: { ttlSeconds?: number; disable?: boolean } + options?: { ttlSeconds?: number; disable?: boolean }, ): Promise { const { ttlSeconds = 120, disable = false } = options ?? {}; diff --git a/apps/web/src/server/service/api-service.integration.test.ts b/apps/web/src/server/service/api-service.integration.test.ts new file mode 100644 index 0000000..16e8c62 --- /dev/null +++ b/apps/web/src/server/service/api-service.integration.test.ts @@ -0,0 +1,53 @@ +import { ApiPermission } from "@prisma/client"; +import { afterAll, beforeEach, describe, expect, it } from "vitest"; +import { addApiKey, getTeamAndApiKey } from "~/server/service/api-service"; +import { createTeam } from "~/test/factories/core"; +import { + closeIntegrationConnections, + integrationEnabled, + resetDatabase, + resetRedis, +} from "~/test/integration/helpers"; + +const describeIntegration = integrationEnabled ? describe : describe.skip; + +describeIntegration("api-service integration", () => { + beforeEach(async () => { + await resetDatabase(); + await resetRedis(); + }); + + afterAll(async () => { + await closeIntegrationConnections(); + }); + + it("creates and verifies API key against postgres", async () => { + const team = await createTeam({ name: "Integration Team" }); + + const apiKey = await addApiKey({ + name: "primary", + permission: ApiPermission.FULL, + teamId: team.id, + }); + + expect(apiKey.startsWith("us_")).toBe(true); + + const result = await getTeamAndApiKey(apiKey); + + expect(result?.team?.id).toBe(team.id); + expect(result?.apiKey.name).toBe("primary"); + }); + + it("rejects domain-restricted key when domain does not belong to team", async () => { + const team = await createTeam({ name: "Team Domain Check" }); + + await expect( + addApiKey({ + name: "restricted", + permission: ApiPermission.SENDING, + teamId: team.id, + domainId: 999999, + }), + ).rejects.toThrow("DOMAIN_NOT_FOUND"); + }); +}); diff --git a/apps/web/src/server/service/idempotency-service.integration.test.ts b/apps/web/src/server/service/idempotency-service.integration.test.ts new file mode 100644 index 0000000..ced1052 --- /dev/null +++ b/apps/web/src/server/service/idempotency-service.integration.test.ts @@ -0,0 +1,78 @@ +import { afterAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { + IDEMPOTENCY_CONSTANTS, + IdempotencyService, +} from "~/server/service/idempotency-service"; +import { + closeIntegrationConnections, + integrationEnabled, + resetRedis, +} from "~/test/integration/helpers"; + +const describeIntegration = integrationEnabled ? describe : describe.skip; + +describeIntegration("idempotency redis integration", () => { + beforeEach(async () => { + await resetRedis(); + }); + + afterAll(async () => { + await closeIntegrationConnections(); + }); + + it("stores and retrieves idempotency result", async () => { + const teamId = 1; + const key = "idem-1"; + + await IdempotencyService.setResult(teamId, key, { + bodyHash: "hash-123", + emailIds: ["em_1"], + }); + + await expect(IdempotencyService.getResult(teamId, key)).resolves.toEqual({ + bodyHash: "hash-123", + emailIds: ["em_1"], + }); + }); + + it("acquires lock only once for same key", async () => { + const teamId = 99; + const key = "lock-test"; + + const first = await IdempotencyService.acquireLock(teamId, key); + const second = await IdempotencyService.acquireLock(teamId, key); + + expect(first).toBe(true); + expect(second).toBe(false); + + await IdempotencyService.releaseLock(teamId, key); + await expect(IdempotencyService.acquireLock(teamId, key)).resolves.toBe( + true, + ); + }); + + it("returns cached response for repeated payload", async () => { + const operation = vi.fn(async () => ({ id: "first", emailIds: ["em_1"] })); + + const options = { + teamId: 25, + idemKey: "request-1", + payload: { to: "a@b.com", subject: "hello" }, + operation, + extractEmailIds: (result: { emailIds: string[] }) => result.emailIds, + formatCachedResponse: (emailIds: string[]) => ({ + id: "cached", + emailIds, + }), + logContext: "integration-test", + }; + + const first = await IdempotencyService.withIdempotency(options); + const second = await IdempotencyService.withIdempotency(options); + + expect(first).toEqual({ id: "first", emailIds: ["em_1"] }); + expect(second).toEqual({ id: "cached", emailIds: ["em_1"] }); + expect(operation).toHaveBeenCalledTimes(1); + expect(IDEMPOTENCY_CONSTANTS.RESULT_TTL_SECONDS).toBe(24 * 60 * 60); + }); +}); diff --git a/apps/web/src/server/utils/email-content.unit.test.ts b/apps/web/src/server/utils/email-content.unit.test.ts new file mode 100644 index 0000000..d7f1dbe --- /dev/null +++ b/apps/web/src/server/utils/email-content.unit.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from "vitest"; +import { escapeHtml, toPlainHtml } from "~/server/utils/email-content"; + +describe("email-content utils", () => { + it("escapes unsafe HTML characters", () => { + const value = ``; + expect(escapeHtml(value)).toBe( + "<script>alert('x') & "y"</script>", + ); + }); + + it("wraps plain text into preformatted safe html", () => { + const result = toPlainHtml("Line 1\nLine <2>"); + expect(result).toContain(" { + it("removes reserved and invalid headers", () => { + expect(sanitizeHeader("x-usesend-email-id", "123")).toBeUndefined(); + expect(sanitizeHeader("X-Test", "ok\r\nInjected: true")).toBeUndefined(); + expect(sanitizeHeader(123, "ok")).toBeUndefined(); + }); + + it("returns undefined for empty sanitized map", () => { + const result = sanitizeCustomHeaders({ + "x-usesend-email-id": "blocked", + "x-bad": "hello\nworld", + }); + + expect(result).toBeUndefined(); + }); + + it("adds defaults and keeps valid custom headers", () => { + const headers = buildHeaders({ + emailId: "em_1", + headers: { + "X-Custom-Trace": "trace-1", + }, + unsubUrl: "https://example.com/unsub", + isBulk: true, + }); + + expect(headers["X-Usesend-Email-ID"]).toBe("em_1"); + expect(headers["X-Custom-Trace"]).toBe("trace-1"); + expect(headers["List-Unsubscribe"]).toBe(""); + expect(headers["Precedence"]).toBe("bulk"); + expect(headers["X-Entity-Ref-ID"]).toBeTypeOf("string"); + }); +}); diff --git a/apps/web/src/server/utils/idempotency.unit.test.ts b/apps/web/src/server/utils/idempotency.unit.test.ts new file mode 100644 index 0000000..bfc13ab --- /dev/null +++ b/apps/web/src/server/utils/idempotency.unit.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vitest"; +import { canonicalizePayload } from "~/server/utils/idempotency"; + +describe("canonicalizePayload", () => { + it("generates same hash for different key ordering", () => { + const payloadA = { b: 1, a: { y: 2, x: 1 } }; + const payloadB = { a: { x: 1, y: 2 }, b: 1 }; + + const a = canonicalizePayload(payloadA); + const b = canonicalizePayload(payloadB); + + expect(a.canonical).toBe(b.canonical); + expect(a.bodyHash).toBe(b.bodyHash); + }); + + it("normalizes dates and undefined values deterministically", () => { + const payload = { + createdAt: new Date("2025-01-01T00:00:00.000Z"), + name: "alpha", + skip: undefined, + }; + + const result = canonicalizePayload(payload); + + expect(result.canonical).toBe( + '{"createdAt":"2025-01-01T00:00:00.000Z","name":"alpha"}', + ); + expect(result.bodyHash).toHaveLength(64); + }); +}); diff --git a/apps/web/src/test/factories/core.ts b/apps/web/src/test/factories/core.ts new file mode 100644 index 0000000..801e745 --- /dev/null +++ b/apps/web/src/test/factories/core.ts @@ -0,0 +1,54 @@ +import { Role, type Prisma, type Team, type User } from "@prisma/client"; +import { db } from "~/server/db"; + +let sequence = 1; + +function nextValue() { + const value = sequence; + sequence += 1; + return value; +} + +export async function createUser(data?: Prisma.UserCreateInput): Promise { + const n = nextValue(); + return db.user.create({ + data: { + email: `user-${n}@example.com`, + isBetaUser: true, + isWaitlisted: false, + ...data, + }, + }); +} + +export async function createTeam(data?: Prisma.TeamCreateInput): Promise { + const n = nextValue(); + return db.team.create({ + data: { + name: `Team ${n}`, + ...data, + }, + }); +} + +export async function attachUserToTeam( + userId: number, + teamId: number, + role: Role = Role.ADMIN, +) { + return db.teamUser.create({ + data: { + userId, + teamId, + role, + }, + }); +} + +export async function createTeamWithUser(role: Role = Role.ADMIN) { + const user = await createUser(); + const team = await createTeam(); + const teamUser = await attachUserToTeam(user.id, team.id, role); + + return { user, team, teamUser }; +} diff --git a/apps/web/src/test/integration/helpers.ts b/apps/web/src/test/integration/helpers.ts new file mode 100644 index 0000000..6f0ca48 --- /dev/null +++ b/apps/web/src/test/integration/helpers.ts @@ -0,0 +1,37 @@ +import { Prisma } from "@prisma/client"; +import { db } from "~/server/db"; +import { getRedis } from "~/server/redis"; + +export const integrationEnabled = process.env.RUN_INTEGRATION === "true"; + +export async function resetDatabase() { + const rows = await db.$queryRaw>(Prisma.sql` + SELECT tablename + FROM pg_tables + WHERE schemaname = 'public' + AND tablename != '_prisma_migrations' + `); + + if (rows.length === 0) { + return; + } + + const tables = rows.map((row) => `"public"."${row.tablename}"`).join(", "); + + await db.$executeRawUnsafe( + `TRUNCATE TABLE ${tables} RESTART IDENTITY CASCADE;`, + ); +} + +export async function resetRedis() { + await getRedis().flushdb(); +} + +export async function closeIntegrationConnections() { + await db.$disconnect(); + + const redis = getRedis(); + if (redis.status !== "end") { + await redis.quit(); + } +} diff --git a/apps/web/src/test/setup/setup-env.ts b/apps/web/src/test/setup/setup-env.ts new file mode 100644 index 0000000..96d56b4 --- /dev/null +++ b/apps/web/src/test/setup/setup-env.ts @@ -0,0 +1,19 @@ +const defaultEnv: Record = { + NODE_ENV: "test", + NEXTAUTH_URL: "http://localhost:3000", + NEXTAUTH_SECRET: "test-secret", + DATABASE_URL: "postgresql://usesend:password@127.0.0.1:54329/usesend_test", + REDIS_URL: "redis://127.0.0.1:6380/15", + AWS_ACCESS_KEY: "test-access-key", + AWS_SECRET_KEY: "test-secret-key", + AWS_DEFAULT_REGION: "us-east-1", + NEXT_PUBLIC_IS_CLOUD: "true", + API_RATE_LIMIT: "2", + AUTH_EMAIL_RATE_LIMIT: "5", +}; + +for (const [key, value] of Object.entries(defaultEnv)) { + if (process.env[key] === undefined) { + process.env[key] = value; + } +} diff --git a/apps/web/src/test/setup/setup-tests.ts b/apps/web/src/test/setup/setup-tests.ts new file mode 100644 index 0000000..bcf5fec --- /dev/null +++ b/apps/web/src/test/setup/setup-tests.ts @@ -0,0 +1,5 @@ +import { afterEach, vi } from "vitest"; + +afterEach(() => { + vi.restoreAllMocks(); +}); diff --git a/apps/web/vitest.api.config.ts b/apps/web/vitest.api.config.ts new file mode 100644 index 0000000..9017dd2 --- /dev/null +++ b/apps/web/vitest.api.config.ts @@ -0,0 +1,11 @@ +import { defineConfig, mergeConfig } from "vitest/config"; +import baseConfig from "./vitest.config"; + +export default mergeConfig( + baseConfig, + defineConfig({ + test: { + include: ["src/**/*.api.test.ts"], + }, + }), +); diff --git a/apps/web/vitest.config.ts b/apps/web/vitest.config.ts new file mode 100644 index 0000000..c15cb09 --- /dev/null +++ b/apps/web/vitest.config.ts @@ -0,0 +1,28 @@ +import { defineConfig } from "vitest/config"; +import tsconfigPaths from "vite-tsconfig-paths"; + +export default defineConfig({ + plugins: [tsconfigPaths()], + test: { + environment: "node", + globals: true, + setupFiles: [ + "./src/test/setup/setup-env.ts", + "./src/test/setup/setup-tests.ts", + ], + clearMocks: true, + restoreMocks: true, + mockReset: true, + coverage: { + provider: "v8", + reporter: ["text", "html"], + include: ["src/**/*.{ts,tsx}"], + exclude: [ + "src/**/*.test.{ts,tsx}", + "src/**/*.spec.{ts,tsx}", + "src/test/**", + "src/env.js", + ], + }, + }, +}); diff --git a/apps/web/vitest.default.config.ts b/apps/web/vitest.default.config.ts new file mode 100644 index 0000000..f71fc6d --- /dev/null +++ b/apps/web/vitest.default.config.ts @@ -0,0 +1,12 @@ +import { defineConfig, mergeConfig } from "vitest/config"; +import baseConfig from "./vitest.config"; + +export default mergeConfig( + baseConfig, + defineConfig({ + test: { + include: ["src/**/*.test.{ts,tsx}", "src/**/*.spec.{ts,tsx}"], + exclude: ["src/**/*.integration.test.{ts,tsx}"], + }, + }), +); diff --git a/apps/web/vitest.integration.config.ts b/apps/web/vitest.integration.config.ts new file mode 100644 index 0000000..b3874f0 --- /dev/null +++ b/apps/web/vitest.integration.config.ts @@ -0,0 +1,17 @@ +import { defineConfig, mergeConfig } from "vitest/config"; +import baseConfig from "./vitest.config"; + +export default mergeConfig( + baseConfig, + defineConfig({ + test: { + include: ["src/**/*.integration.test.ts"], + pool: "forks", + poolOptions: { + forks: { + singleFork: true, + }, + }, + }, + }), +); diff --git a/apps/web/vitest.trpc.config.ts b/apps/web/vitest.trpc.config.ts new file mode 100644 index 0000000..b174035 --- /dev/null +++ b/apps/web/vitest.trpc.config.ts @@ -0,0 +1,11 @@ +import { defineConfig, mergeConfig } from "vitest/config"; +import baseConfig from "./vitest.config"; + +export default mergeConfig( + baseConfig, + defineConfig({ + test: { + include: ["src/**/*.trpc.test.ts"], + }, + }), +); diff --git a/apps/web/vitest.unit.config.ts b/apps/web/vitest.unit.config.ts new file mode 100644 index 0000000..512d8ae --- /dev/null +++ b/apps/web/vitest.unit.config.ts @@ -0,0 +1,11 @@ +import { defineConfig, mergeConfig } from "vitest/config"; +import baseConfig from "./vitest.config"; + +export default mergeConfig( + baseConfig, + defineConfig({ + test: { + include: ["src/**/*.unit.test.ts"], + }, + }), +); diff --git a/docker/testing/compose.yml b/docker/testing/compose.yml new file mode 100644 index 0000000..748714c --- /dev/null +++ b/docker/testing/compose.yml @@ -0,0 +1,29 @@ +name: unsend-test + +services: + postgres: + image: postgres:16 + restart: unless-stopped + environment: + POSTGRES_USER: usesend + POSTGRES_PASSWORD: password + POSTGRES_DB: usesend_test + ports: + - "54329:5432" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U usesend -d usesend_test"] + interval: 5s + timeout: 5s + retries: 20 + + redis: + image: redis:7 + restart: unless-stopped + command: ["redis-server", "--maxmemory-policy", "noeviction"] + ports: + - "6380:6379" + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + timeout: 5s + retries: 20 diff --git a/package.json b/package.json index 092f97a..3c64d43 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,16 @@ "build:web:local": "pnpm load-env -- turbo build --filter=@usesend/email-editor --filter=web ", "start:web:local": "pnpm load-env -- cd apps/web && pnpm start", "dev": "pnpm load-env -- turbo dev", + "test:web": "pnpm --filter=web test", + "test:web:all": "pnpm test:infra:up && (pnpm --filter=web test:all; code=$?; pnpm test:infra:down; exit $code)", + "test:web:all:raw": "pnpm --filter=web test:all", + "test:web:unit": "pnpm --filter=web test:unit", + "test:web:trpc": "pnpm --filter=web test:trpc", + "test:web:api": "pnpm --filter=web test:api", + "test:web:integration": "pnpm --filter=web test:integration", + "test:web:integration:full": "pnpm test:infra:up && (pnpm --filter=web test:integration:full; code=$?; pnpm test:infra:down; exit $code)", + "test:infra:up": "docker compose -f docker/testing/compose.yml up -d --wait", + "test:infra:down": "docker compose -f docker/testing/compose.yml down -v", "dev:docs": "cd apps/docs && mintlify dev", "dev:marketing": "cd apps/marketing && turbo dev", "lint": "turbo lint", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 94fa290..7518ca0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -357,6 +357,9 @@ importers: '@usesend/typescript-config': specifier: workspace:* version: link:../../packages/typescript-config + '@vitest/coverage-v8': + specifier: ^3.2.4 + version: 3.2.4(vitest@3.2.4) eslint: specifier: ^8.57.1 version: 8.57.1 @@ -378,6 +381,12 @@ importers: typescript: specifier: ^5.8.3 version: 5.8.3 + vite-tsconfig-paths: + specifier: ^5.1.4 + version: 5.1.4(typescript@5.8.3)(vite@5.4.18) + vitest: + specifier: ^3.2.4 + version: 3.2.4(@types/node@22.15.2) packages/email-editor: dependencies: @@ -2162,6 +2171,11 @@ packages: '@babel/helper-string-parser': 7.25.9 '@babel/helper-validator-identifier': 7.25.9 + /@bcoe/v8-coverage@1.0.2: + resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} + engines: {node: '>=18'} + dev: true + /@canvas/image-data@1.1.0: resolution: {integrity: sha512-QdObRRjRbcXGmM1tmJ+MrHcaz1MftF2+W7YI+MsphnsCrmtyfS0d5qJbk0MeSbUeyM/jCb0hmnkXPsy026L7dA==} dev: true @@ -2209,7 +2223,6 @@ packages: cpu: [ppc64] os: [aix] requiresBuild: true - dev: false optional: true /@esbuild/aix-ppc64@0.24.2: @@ -2235,7 +2248,6 @@ packages: cpu: [arm64] os: [android] requiresBuild: true - dev: false optional: true /@esbuild/android-arm64@0.24.2: @@ -2261,7 +2273,6 @@ packages: cpu: [arm] os: [android] requiresBuild: true - dev: false optional: true /@esbuild/android-arm@0.24.2: @@ -2287,7 +2298,6 @@ packages: cpu: [x64] os: [android] requiresBuild: true - dev: false optional: true /@esbuild/android-x64@0.24.2: @@ -2313,7 +2323,6 @@ packages: cpu: [arm64] os: [darwin] requiresBuild: true - dev: false optional: true /@esbuild/darwin-arm64@0.24.2: @@ -2339,7 +2348,6 @@ packages: cpu: [x64] os: [darwin] requiresBuild: true - dev: false optional: true /@esbuild/darwin-x64@0.24.2: @@ -2365,7 +2373,6 @@ packages: cpu: [arm64] os: [freebsd] requiresBuild: true - dev: false optional: true /@esbuild/freebsd-arm64@0.24.2: @@ -2391,7 +2398,6 @@ packages: cpu: [x64] os: [freebsd] requiresBuild: true - dev: false optional: true /@esbuild/freebsd-x64@0.24.2: @@ -2417,7 +2423,6 @@ packages: cpu: [arm64] os: [linux] requiresBuild: true - dev: false optional: true /@esbuild/linux-arm64@0.24.2: @@ -2443,7 +2448,6 @@ packages: cpu: [arm] os: [linux] requiresBuild: true - dev: false optional: true /@esbuild/linux-arm@0.24.2: @@ -2469,7 +2473,6 @@ packages: cpu: [ia32] os: [linux] requiresBuild: true - dev: false optional: true /@esbuild/linux-ia32@0.24.2: @@ -2495,7 +2498,6 @@ packages: cpu: [loong64] os: [linux] requiresBuild: true - dev: false optional: true /@esbuild/linux-loong64@0.24.2: @@ -2521,7 +2523,6 @@ packages: cpu: [mips64el] os: [linux] requiresBuild: true - dev: false optional: true /@esbuild/linux-mips64el@0.24.2: @@ -2547,7 +2548,6 @@ packages: cpu: [ppc64] os: [linux] requiresBuild: true - dev: false optional: true /@esbuild/linux-ppc64@0.24.2: @@ -2573,7 +2573,6 @@ packages: cpu: [riscv64] os: [linux] requiresBuild: true - dev: false optional: true /@esbuild/linux-riscv64@0.24.2: @@ -2599,7 +2598,6 @@ packages: cpu: [s390x] os: [linux] requiresBuild: true - dev: false optional: true /@esbuild/linux-s390x@0.24.2: @@ -2625,7 +2623,6 @@ packages: cpu: [x64] os: [linux] requiresBuild: true - dev: false optional: true /@esbuild/linux-x64@0.24.2: @@ -2668,7 +2665,6 @@ packages: cpu: [x64] os: [netbsd] requiresBuild: true - dev: false optional: true /@esbuild/netbsd-x64@0.24.2: @@ -2711,7 +2707,6 @@ packages: cpu: [x64] os: [openbsd] requiresBuild: true - dev: false optional: true /@esbuild/openbsd-x64@0.24.2: @@ -2737,7 +2732,6 @@ packages: cpu: [x64] os: [sunos] requiresBuild: true - dev: false optional: true /@esbuild/sunos-x64@0.24.2: @@ -2763,7 +2757,6 @@ packages: cpu: [arm64] os: [win32] requiresBuild: true - dev: false optional: true /@esbuild/win32-arm64@0.24.2: @@ -2789,7 +2782,6 @@ packages: cpu: [ia32] os: [win32] requiresBuild: true - dev: false optional: true /@esbuild/win32-ia32@0.24.2: @@ -2815,7 +2807,6 @@ packages: cpu: [x64] os: [win32] requiresBuild: true - dev: false optional: true /@esbuild/win32-x64@0.24.2: @@ -3621,6 +3612,11 @@ packages: engines: {node: '>=12'} dev: false + /@istanbuljs/schema@0.1.3: + resolution: {integrity: sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==} + engines: {node: '>=8'} + dev: true + /@jridgewell/gen-mapping@0.3.8: resolution: {integrity: sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA==} engines: {node: '>=6.0.0'} @@ -3646,6 +3642,13 @@ packages: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.0 + /@jridgewell/trace-mapping@0.3.31: + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.0 + dev: true + /@jsep-plugin/assignment@1.3.0(jsep@1.4.0): resolution: {integrity: sha512-VVgV+CXrhbMI3aSusQyclHkenWSAm95WaiKrMxRFam3JSUiIaQjoMIw2sEs/OX4XifnqeQUN4DYbJjlA8EfktQ==} engines: {node: '>= 10.16.0'} @@ -8164,6 +8167,13 @@ packages: '@babel/types': 7.27.0 dev: false + /@types/chai@5.2.3: + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + dev: true + /@types/cookie@0.4.1: resolution: {integrity: sha512-XW/Aa8APYr6jSVVA1y/DEIZX0/GMKLEVekNG727R8cs56ahETkRAy/3DR7+fJyh7oUgGwNQaRfXCun0+KbWY7Q==} dev: true @@ -8225,6 +8235,10 @@ packages: dependencies: '@types/ms': 2.1.0 + /@types/deep-eql@4.0.2: + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + dev: true + /@types/es-aggregate-error@1.0.6: resolution: {integrity: sha512-qJ7LIFp06h1QE1aVxbVd+zJP2wdaugYXYfd6JxsyRMrYHaxb6itXPogW2tz+ylUJ1n1b+JF1PHyYCfYHm0dvUg==} dependencies: @@ -8985,6 +8999,96 @@ packages: - supports-color dev: false + /@vitest/coverage-v8@3.2.4(vitest@3.2.4): + resolution: {integrity: sha512-EyF9SXU6kS5Ku/U82E259WSnvg6c8KTjppUncuNdm5QHpe17mwREHnjDzozC8x9MZ0xfBUFSaLkRv4TMA75ALQ==} + peerDependencies: + '@vitest/browser': 3.2.4 + vitest: 3.2.4 + peerDependenciesMeta: + '@vitest/browser': + optional: true + dependencies: + '@ampproject/remapping': 2.3.0 + '@bcoe/v8-coverage': 1.0.2 + ast-v8-to-istanbul: 0.3.11 + debug: 4.4.3 + istanbul-lib-coverage: 3.2.2 + istanbul-lib-report: 3.0.1 + istanbul-lib-source-maps: 5.0.6 + istanbul-reports: 3.2.0 + magic-string: 0.30.17 + magicast: 0.3.5 + std-env: 3.9.0 + test-exclude: 7.0.1 + tinyrainbow: 2.0.0 + vitest: 3.2.4(@types/node@22.15.2) + transitivePeerDependencies: + - supports-color + dev: true + + /@vitest/expect@3.2.4: + resolution: {integrity: sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==} + dependencies: + '@types/chai': 5.2.3 + '@vitest/spy': 3.2.4 + '@vitest/utils': 3.2.4 + chai: 5.3.3 + tinyrainbow: 2.0.0 + dev: true + + /@vitest/mocker@3.2.4(vite@5.4.18): + resolution: {integrity: sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==} + peerDependencies: + msw: ^2.4.9 + vite: ^5.0.0 || ^6.0.0 || ^7.0.0-0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + dependencies: + '@vitest/spy': 3.2.4 + estree-walker: 3.0.3 + magic-string: 0.30.17 + vite: 5.4.18(@types/node@22.15.2) + dev: true + + /@vitest/pretty-format@3.2.4: + resolution: {integrity: sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==} + dependencies: + tinyrainbow: 2.0.0 + dev: true + + /@vitest/runner@3.2.4: + resolution: {integrity: sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==} + dependencies: + '@vitest/utils': 3.2.4 + pathe: 2.0.3 + strip-literal: 3.1.0 + dev: true + + /@vitest/snapshot@3.2.4: + resolution: {integrity: sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==} + dependencies: + '@vitest/pretty-format': 3.2.4 + magic-string: 0.30.17 + pathe: 2.0.3 + dev: true + + /@vitest/spy@3.2.4: + resolution: {integrity: sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==} + dependencies: + tinyspy: 4.0.4 + dev: true + + /@vitest/utils@3.2.4: + resolution: {integrity: sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==} + dependencies: + '@vitest/pretty-format': 3.2.4 + loupe: 3.2.1 + tinyrainbow: 2.0.0 + dev: true + /abbrev@2.0.0: resolution: {integrity: sha512-6/mh1E2u2YgEsCHdY0Yx5oW+61gZU+1vXaoiHHrpKeuRNNgFvS+/jrwHiQhB5apAf5oB7UB7E19ol2R2LKH8hQ==} engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} @@ -9328,6 +9432,11 @@ packages: is-array-buffer: 3.0.5 dev: true + /assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + dev: true + /ast-types-flow@0.0.8: resolution: {integrity: sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==} dev: true @@ -9339,6 +9448,14 @@ packages: tslib: 2.8.1 dev: true + /ast-v8-to-istanbul@0.3.11: + resolution: {integrity: sha512-Qya9fkoofMjCBNVdWINMjB5KZvkYfaO9/anwkWnjxibpWUxo5iHl2sOdP7/uAqaRuUYuoo8rDwnbaaKVFxoUvw==} + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + estree-walker: 3.0.3 + js-tokens: 10.0.0 + dev: true + /astring@1.9.0: resolution: {integrity: sha512-LElXdjswlqjWrPpJFg1Fx4wpkOCxj1TDHlSV4PlaRxHGWko024xICaa97ZkMfs6DRKlCguiAI+rbXv5GWwXIkg==} hasBin: true @@ -9691,6 +9808,17 @@ packages: /ccount@2.0.1: resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} + /chai@5.3.3: + resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} + engines: {node: '>=18'} + dependencies: + assertion-error: 2.0.1 + check-error: 2.1.3 + deep-eql: 5.0.2 + loupe: 3.2.1 + pathval: 2.0.1 + dev: true + /chalk@4.1.2: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} @@ -9733,6 +9861,11 @@ packages: resolution: {integrity: sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ==} dev: true + /check-error@2.1.3: + resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} + engines: {node: '>= 16'} + dev: true + /chokidar@3.5.3: resolution: {integrity: sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==} engines: {node: '>= 8.10.0'} @@ -10279,6 +10412,18 @@ packages: ms: 2.1.3 supports-color: 9.4.0 + /debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + dependencies: + ms: 2.1.3 + dev: true + /decimal.js-light@2.5.1: resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==} dev: false @@ -10317,6 +10462,11 @@ packages: mimic-response: 3.1.0 dev: true + /deep-eql@5.0.2: + resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} + engines: {node: '>=6'} + dev: true + /deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} @@ -10761,6 +10911,10 @@ packages: safe-array-concat: 1.1.3 dev: true + /es-module-lexer@1.7.0: + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + dev: true + /es-object-atoms@1.1.1: resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} engines: {node: '>= 0.4'} @@ -10852,7 +11006,6 @@ packages: '@esbuild/win32-arm64': 0.21.5 '@esbuild/win32-ia32': 0.21.5 '@esbuild/win32-x64': 0.21.5 - dev: false /esbuild@0.24.2: resolution: {integrity: sha512-+9egpBW8I3CD5XPe0n6BfT5fxLzxrlDzqydF3aviG+9ni1lDC/OvMHcxqEFV0+LANZG5R1bFMWfUrjVsdwxJvA==} @@ -11495,6 +11648,11 @@ packages: resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==} dev: false + /expect-type@1.3.0: + resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} + engines: {node: '>=12.0.0'} + dev: true + /express@4.18.2: resolution: {integrity: sha512-5/PsL6iGPdfQ/lKM1UuielYgv3BUoJfz1aUwU9vHZ+J7gyvwdQXFEBIEIaxeGf0GIcreATNyBExtalisDbuMqQ==} engines: {node: '>= 0.10.0'} @@ -11673,6 +11831,18 @@ packages: picomatch: 4.0.2 dev: true + /fdir@6.5.0(picomatch@4.0.3): + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + dependencies: + picomatch: 4.0.3 + dev: true + /file-entry-cache@6.0.1: resolution: {integrity: sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==} engines: {node: ^10.12.0 || >=12.0.0} @@ -12076,6 +12246,10 @@ packages: unicorn-magic: 0.1.0 dev: false + /globrex@0.1.2: + resolution: {integrity: sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg==} + dev: true + /gopd@1.2.0: resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} engines: {node: '>= 0.4'} @@ -12453,6 +12627,10 @@ packages: resolution: {integrity: sha512-fxfswuADQ6N6RmCUYoCEIw09Zbk/h8GJSJsbiQ3Uw3mkQegJ5b7Eu5Tpxl2xDUq9meWmivHe0GFieG2qHl2j4A==} dev: false + /html-escaper@2.0.2: + resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + dev: true + /html-to-text@9.0.5: resolution: {integrity: sha512-qY60FjREgVZL03vJU6IfMV4GDjGBIoOyvuFdpBDIX9yTlDw0TjxVBQp+P8NvpdIXNJvfWBTNul7fsAQJq2FNpg==} engines: {node: '>=14'} @@ -13061,6 +13239,39 @@ packages: /isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + /istanbul-lib-coverage@3.2.2: + resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} + engines: {node: '>=8'} + dev: true + + /istanbul-lib-report@3.0.1: + resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==} + engines: {node: '>=10'} + dependencies: + istanbul-lib-coverage: 3.2.2 + make-dir: 4.0.0 + supports-color: 7.2.0 + dev: true + + /istanbul-lib-source-maps@5.0.6: + resolution: {integrity: sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==} + engines: {node: '>=10'} + dependencies: + '@jridgewell/trace-mapping': 0.3.25 + debug: 4.4.3 + istanbul-lib-coverage: 3.2.2 + transitivePeerDependencies: + - supports-color + dev: true + + /istanbul-reports@3.2.0: + resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} + engines: {node: '>=8'} + dependencies: + html-escaper: 2.0.2 + istanbul-lib-report: 3.0.1 + dev: true + /iterator.prototype@1.1.5: resolution: {integrity: sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==} engines: {node: '>= 0.4'} @@ -13122,9 +13333,17 @@ packages: engines: {node: '>=0.10.0'} dev: true + /js-tokens@10.0.0: + resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} + dev: true + /js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + /js-tokens@9.0.1: + resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} + dev: true + /js-yaml@3.14.1: resolution: {integrity: sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==} hasBin: true @@ -13462,6 +13681,10 @@ packages: dependencies: js-tokens: 4.0.0 + /loupe@3.2.1: + resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} + dev: true + /lowercase-keys@3.0.0: resolution: {integrity: sha512-ozCC6gdQ+glXOQsveKD0YsDy8DSQFjDTz4zyzEHNV5+JP5D62LmfDZ6o1cycFx9ouG940M5dE8C8CTewdj2YWQ==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} @@ -13504,7 +13727,14 @@ packages: resolution: {integrity: sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==} dependencies: '@jridgewell/sourcemap-codec': 1.5.0 - dev: false + + /magicast@0.3.5: + resolution: {integrity: sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==} + dependencies: + '@babel/parser': 7.27.0 + '@babel/types': 7.27.0 + source-map-js: 1.2.1 + dev: true /mailparser@3.7.2: resolution: {integrity: sha512-iI0p2TCcIodR1qGiRoDBBwboSSff50vQAWytM5JRggLfABa4hHYCf3YVujtuzV454xrOP352VsAPIzviqMTo4Q==} @@ -13529,6 +13759,13 @@ packages: libqp: 2.1.1 dev: false + /make-dir@4.0.0: + resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} + engines: {node: '>=10'} + dependencies: + semver: 7.7.2 + dev: true + /markdown-extensions@2.0.0: resolution: {integrity: sha512-o5vL7aDWatOTX8LzaS1WMoaoxIiLRQJuIKKe2wAw6IeULDHaqbiqiggmx+pKvZDb1Sj+pE46Sn1T7lCqfFtg1Q==} engines: {node: '>=16'} @@ -15032,6 +15269,15 @@ packages: engines: {node: '>=12'} dev: false + /pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + dev: true + + /pathval@2.0.1: + resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} + engines: {node: '>= 14.16'} + dev: true + /peberminta@0.9.0: resolution: {integrity: sha512-XIxfHpEuSJbITd1H3EeQwpcZbTLHc+VVr8ANI9t5sit565tsI4/xK3KWTUFE2e6QiangUkh3B0jihzmGnNrRsQ==} dev: false @@ -15052,6 +15298,11 @@ packages: engines: {node: '>=12'} dev: true + /picomatch@4.0.3: + resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} + engines: {node: '>=12'} + dev: true + /pify@2.3.0: resolution: {integrity: sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==} engines: {node: '>=0.10.0'} @@ -16976,6 +17227,10 @@ packages: side-channel-map: 1.0.1 side-channel-weakmap: 1.0.2 + /siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + dev: true + /signal-exit@3.0.7: resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} dev: true @@ -17210,6 +17465,10 @@ packages: escape-string-regexp: 2.0.0 dev: true + /stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + dev: true + /standard-as-callback@2.1.0: resolution: {integrity: sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==} dev: false @@ -17221,7 +17480,6 @@ packages: /std-env@3.9.0: resolution: {integrity: sha512-UGvjygr6F6tpH7o2qyqR6QYpwraIjKSdtzyBdyytFOHmPZY917kwdwLG0RbOjWOnKmnm3PeHjaoLLMie7kPLQw==} - dev: false /streamx@2.22.0: resolution: {integrity: sha512-sLh1evHOzBy/iWRiR6d1zRcLao4gGZr3C1kzNz4fopCOKJb6xD9ub8Mpi9Mr1R6id5o43S+d93fI48UC5uM9aw==} @@ -17358,6 +17616,12 @@ packages: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} + /strip-literal@3.1.0: + resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==} + dependencies: + js-tokens: 9.0.1 + dev: true + /stripe@18.0.0: resolution: {integrity: sha512-3Fs33IzKUby//9kCkCa1uRpinAoTvj6rJgQ2jrBEysoxEvfsclvXdna1amyEYbA2EKkjynuB4+L/kleCCaWTpA==} engines: {node: '>=12.*'} @@ -17549,6 +17813,15 @@ packages: yallist: 4.0.0 dev: true + /test-exclude@7.0.1: + resolution: {integrity: sha512-pFYqmTw68LXVjeWJMST4+borgQP2AyMNbg1BpZh9LbyhUeNkeaPF9gzfPGUAnSMV3qPYdWUwDIjjCLiSDOl7vg==} + engines: {node: '>=18'} + dependencies: + '@istanbuljs/schema': 0.1.3 + glob: 10.4.5 + minimatch: 9.0.5 + dev: true + /text-decoder@1.2.3: resolution: {integrity: sha512-3/o9z3X0X0fTupwsYvR03pJ/DjWuqqrfwBgTQzdWDiQSm9KitAyz/9WqsT2JQW7KV2m+bC2ol/zqpW37NHxLaA==} dependencies: @@ -17583,6 +17856,10 @@ packages: resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} dev: false + /tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + dev: true + /tinyexec@0.3.2: resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} dev: true @@ -17595,6 +17872,29 @@ packages: picomatch: 4.0.2 dev: true + /tinyglobby@0.2.15: + resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} + engines: {node: '>=12.0.0'} + dependencies: + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + dev: true + + /tinypool@1.1.1: + resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} + engines: {node: ^18.0.0 || >=20.0.0} + dev: true + + /tinyrainbow@2.0.0: + resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==} + engines: {node: '>=14.0.0'} + dev: true + + /tinyspy@4.0.4: + resolution: {integrity: sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==} + engines: {node: '>=14.0.0'} + dev: true + /tippy.js@6.3.7: resolution: {integrity: sha512-E1d3oP2emgJ9dRQZdf3Kkn0qJgI6ZLpyS5z6ZkY1DF3kaQaBsGZsndEpHwx+eC+tYM41HaSNvNtLx8tU57FzTQ==} dependencies: @@ -17686,6 +17986,19 @@ packages: /ts-interface-checker@0.1.13: resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} + /tsconfck@3.1.6(typescript@5.8.3): + resolution: {integrity: sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w==} + engines: {node: ^18 || >=20} + hasBin: true + peerDependencies: + typescript: ^5.0.0 + peerDependenciesMeta: + typescript: + optional: true + dependencies: + typescript: 5.8.3 + dev: true + /tsconfig-paths@3.15.0: resolution: {integrity: sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==} dependencies: @@ -18426,6 +18739,45 @@ packages: d3-timer: 3.0.1 dev: false + /vite-node@3.2.4(@types/node@22.15.2): + resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + dependencies: + cac: 6.7.14 + debug: 4.4.3 + es-module-lexer: 1.7.0 + pathe: 2.0.3 + vite: 5.4.18(@types/node@22.15.2) + transitivePeerDependencies: + - '@types/node' + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + dev: true + + /vite-tsconfig-paths@5.1.4(typescript@5.8.3)(vite@5.4.18): + resolution: {integrity: sha512-cYj0LRuLV2c2sMqhqhGpaO3LretdtMn/BVX4cPLanIZuwwrkVl+lK84E/miEXkCHWXuq65rhNN4rXsBcOB3S4w==} + peerDependencies: + vite: '*' + peerDependenciesMeta: + vite: + optional: true + dependencies: + debug: 4.4.0(supports-color@9.4.0) + globrex: 0.1.2 + tsconfck: 3.1.6(typescript@5.8.3) + vite: 5.4.18(@types/node@22.15.2) + transitivePeerDependencies: + - supports-color + - typescript + dev: true + /vite@5.4.18(@types/node@22.15.2): resolution: {integrity: sha512-1oDcnEp3lVyHCuQ2YFelM4Alm2o91xNoMncRm1U7S+JdYfYOvbiGZ3/CxGttrOu2M/KcGz7cRC2DoNUA6urmMA==} engines: {node: ^18.0.0 || >=20.0.0} @@ -18463,7 +18815,70 @@ packages: rollup: 4.40.0 optionalDependencies: fsevents: 2.3.3 - dev: false + + /vitest@3.2.4(@types/node@22.15.2): + resolution: {integrity: sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@types/debug': ^4.1.12 + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + '@vitest/browser': 3.2.4 + '@vitest/ui': 3.2.4 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@types/debug': + optional: true + '@types/node': + optional: true + '@vitest/browser': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + dependencies: + '@types/chai': 5.2.3 + '@types/node': 22.15.2 + '@vitest/expect': 3.2.4 + '@vitest/mocker': 3.2.4(vite@5.4.18) + '@vitest/pretty-format': 3.2.4 + '@vitest/runner': 3.2.4 + '@vitest/snapshot': 3.2.4 + '@vitest/spy': 3.2.4 + '@vitest/utils': 3.2.4 + chai: 5.3.3 + debug: 4.4.3 + expect-type: 1.3.0 + magic-string: 0.30.17 + pathe: 2.0.3 + picomatch: 4.0.2 + std-env: 3.9.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinyglobby: 0.2.15 + tinypool: 1.1.1 + tinyrainbow: 2.0.0 + vite: 5.4.18(@types/node@22.15.2) + vite-node: 3.2.4(@types/node@22.15.2) + why-is-node-running: 2.3.0 + transitivePeerDependencies: + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + dev: true /w3c-keyname@2.2.8: resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==} @@ -18553,6 +18968,15 @@ packages: dependencies: isexe: 2.0.0 + /why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + dev: true + /widest-line@5.0.0: resolution: {integrity: sha512-c9bZp7b5YtRj2wOe6dlj32MK+Bx/M/d+9VB2SHM1OtsUHR0aV0tdP6DWh/iMt0kWi1t5g1Iudu6hQRNd1A4PVA==} engines: {node: '>=18'}