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
+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"],
},
}),
);