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:
@@ -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
|
||||
@@ -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
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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 +
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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(
|
||||
"<script>alert('x') & "y"</script>",
|
||||
);
|
||||
});
|
||||
|
||||
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 <2>");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { afterEach, vi } from "vitest";
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
@@ -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"],
|
||||
},
|
||||
}),
|
||||
);
|
||||
@@ -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",
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -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}"],
|
||||
},
|
||||
}),
|
||||
);
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
@@ -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"],
|
||||
},
|
||||
}),
|
||||
);
|
||||
@@ -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"],
|
||||
},
|
||||
}),
|
||||
);
|
||||
Reference in New Issue
Block a user