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
This commit is contained in:
KM Koushik
2026-02-16 09:13:29 +11:00
committed by GitHub
parent 09bdb8aaad
commit 487902421b
33 changed files with 1676 additions and 39 deletions
+86
View File
@@ -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
+7 -1
View File
@@ -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
+14
View File
@@ -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
+65
View File
@@ -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.
+15 -1
View File
@@ -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"
@@ -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" });
});
});
@@ -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");
});
});
+2 -2
View File
@@ -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 +
+35
View File
@@ -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);
});
});
@@ -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",
});
});
});
+134
View File
@@ -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<string, unknown> | 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",
});
});
});
+6 -6
View File
@@ -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) => {
@@ -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",
},
});
});
});
@@ -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);
});
});
+2 -2
View File
@@ -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<T>(
key: string,
fetcher: () => Promise<T>,
options?: { ttlSeconds?: number; disable?: boolean }
options?: { ttlSeconds?: number; disable?: boolean },
): Promise<T> {
const { ttlSeconds = 120, disable = false } = options ?? {};
@@ -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");
});
});
@@ -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);
});
});
@@ -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 = `<script>alert('x') & \"y\"</script>`;
expect(escapeHtml(value)).toBe(
"&lt;script&gt;alert(&#39;x&#39;) &amp; &quot;y&quot;&lt;/script&gt;",
);
});
it("wraps plain text into preformatted safe html", () => {
const result = toPlainHtml("Line 1\nLine <2>");
expect(result).toContain("<pre");
expect(result).toContain("Line 1");
expect(result).toContain("Line &lt;2&gt;");
});
});
@@ -0,0 +1,40 @@
import { describe, expect, it } from "vitest";
import {
buildHeaders,
sanitizeCustomHeaders,
sanitizeHeader,
} from "~/server/utils/email-headers";
describe("email header sanitization", () => {
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("<https://example.com/unsub>");
expect(headers["Precedence"]).toBe("bulk");
expect(headers["X-Entity-Ref-ID"]).toBeTypeOf("string");
});
});
@@ -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);
});
});
+54
View File
@@ -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<User> {
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<Team> {
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 };
}
+37
View File
@@ -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<Array<{ tablename: string }>>(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();
}
}
+19
View File
@@ -0,0 +1,19 @@
const defaultEnv: Record<string, string> = {
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;
}
}
+5
View File
@@ -0,0 +1,5 @@
import { afterEach, vi } from "vitest";
afterEach(() => {
vi.restoreAllMocks();
});
+11
View File
@@ -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"],
},
}),
);
+28
View File
@@ -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",
],
},
},
});
+12
View File
@@ -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}"],
},
}),
);
+17
View File
@@ -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,
},
},
},
}),
);
+11
View File
@@ -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"],
},
}),
);
+11
View File
@@ -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"],
},
}),
);
+29
View File
@@ -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
+10
View File
@@ -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",
+451 -27
View File
@@ -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'}