Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,5 +33,8 @@ jobs:
- name: Type check
run: pnpm check-types

- name: Test
run: pnpm test

- name: Build
run: pnpm build
10 changes: 10 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
# Testing requirements

- Keep API tests under `apps/api/test`; never colocate tests in `apps/api/src`.
- Keep web tests under `apps/web/test`; never colocate tests in `apps/web/app`, `components`, `hooks`, `lib`, `services`, or `store`.
- Add or update tests whenever runtime code changes. This includes utilities, middleware, hooks, services, stores, components, pages, route wiring, and configuration.
- New behavior must cover success, failure, validation, and relevant edge cases.
- Use deterministic Faker factories or explicit fixture values. Do not depend on random data or test execution order.
- Preserve the 100% statements, branches, functions, and lines thresholds for the DB-free coverage boundary defined in each app's `vitest.config.ts`.
- Do not weaken coverage thresholds or add coverage exclusions merely to make CI pass. Document any legitimate infrastructure exclusion in the relevant Vitest configuration.
- Run the affected app's tests, coverage, type checks, and lint before considering work complete.
10 changes: 8 additions & 2 deletions apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,11 @@
"dev": "bun run --hot src/server.ts",
"build": "tsc",
"start": "bun run src/server.ts",
"check-types": "tsc --noEmit",
"check-types": "tsc --noEmit && tsc --noEmit -p test/tsconfig.json",
"lint": "eslint .",
"test": "TZ=UTC vitest run",
"test:unit": "TZ=UTC vitest run",
"test:coverage": "TZ=UTC vitest run --coverage",
"db:generate": "pnpm build && drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
"db:push": "pnpm build && drizzle-kit push",
Expand All @@ -29,12 +32,15 @@
"zod": "catalog:"
},
"devDependencies": {
"@faker-js/faker": "^10.0.0",
"@types/bun": "^1.2.14",
"@vitest/coverage-v8": "4.1.9",
"@workspace/eslint-config": "workspace:*",
"@workspace/typescript-config": "workspace:*",
"dotenv": "^17.3.1",
"drizzle-kit": "^0.31.1",
"eslint": "^9.39.2",
"typescript": "catalog:"
"typescript": "catalog:",
"vitest": "4.1.9"
}
}
64 changes: 64 additions & 0 deletions apps/api/test/config/config.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { afterEach, describe, expect, it, vi } from "vitest";

const originalEnv = { ...process.env };

afterEach(() => {
process.env = { ...originalEnv };
vi.resetModules();
});

describe("runtime configuration", () => {
it("parses valid API environment values and defaults", async () => {
process.env = {
...originalEnv,
NODE_ENV: "development",
DATABASE_URL: "postgres://example",
WEB_URL: "https://web.example.test",
BETTER_AUTH_SECRET: "x".repeat(32),
BETTER_AUTH_URL: "https://api.example.test",
RESEND_API_KEY: "key",
RESEND_FROM_EMAIL: "test@example.test",
GOOGLE_CLIENT_ID: "client",
GOOGLE_CLIENT_SECRET: "secret",
};
delete process.env.PORT;

const { env } = await import("@/config/env.config.js");

expect(env.PORT).toBe(3001);
expect(env.NODE_ENV).toBe("development");
});

it("reports all invalid environment values", async () => {
process.env = { NODE_ENV: "test" };

await expect(import("@/config/env.config.js")).rejects.toThrow(
/Invalid environment variables:\nNODE_ENV:/,
);
});

it("labels schema-level environment issues", async () => {
vi.doMock("@workspace/validators/schemas/env", () => ({
apiEnvSchema: {
safeParse: () => ({
success: false,
error: { issues: [{ path: [], message: "invalid configuration" }] },
}),
},
}));

await expect(import("@/config/env.config.js")).rejects.toThrow("env: invalid configuration");
vi.doUnmock("@workspace/validators/schemas/env");
});

it.each([
["production", "info"],
["development", "debug"],
])("sets %s logging to %s", async (nodeEnv, level) => {
process.env.NODE_ENV = nodeEnv;

const { logger } = await import("@/config/logger.config.js");

expect(logger.level).toBe(level);
});
});
80 changes: 80 additions & 0 deletions apps/api/test/lib/core.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { faker } from "@faker-js/faker";
import { Hono } from "hono";
import { beforeEach, describe, expect, it } from "vitest";
import { STATUS_CODES } from "@/constants/status-codes.js";
import { getHealth } from "@/controllers/health.controller.js";
import { AppError } from "@/lib/app-error.js";
import { toDate } from "@/lib/date.js";
import { getTokenFromAuthUrl } from "@/lib/get-token-from-url.js";
import { getSessionWorkspaceId } from "@/lib/workspace.js";

describe("DB-free API helpers", () => {
beforeEach(() => {
faker.seed(20260706);
});

it("returns the health response through the public HTTP interface", async () => {
const app = new Hono().get("/health", getHealth);

const response = await app.request("/health");

expect(response.status).toBe(STATUS_CODES.OK);
await expect(response.json()).resolves.toEqual({
success: true,
data: { status: "ok" },
});
});

it("normalizes supported date inputs", () => {
const date = faker.date.past();

expect(toDate(undefined)).toBeUndefined();
expect(toDate(date)).toBe(date);
expect(toDate(date.toISOString())).toEqual(date);
});

it("extracts auth tokens from query strings and paths", () => {
const token = faker.string.uuid();

expect(getTokenFromAuthUrl(`https://api.example.test/verify?token=${token}`)).toBe(token);
expect(getTokenFromAuthUrl(`https://api.example.test/verify/${token}`)).toBe(token);
expect(getTokenFromAuthUrl("https://api.example.test")).toBeNull();
expect(getTokenFromAuthUrl("not a url")).toBeNull();
});

it("returns the active workspace and rejects sessions without one", async () => {
const workspaceId = faker.string.uuid();
const app = new Hono<{
Variables: { session: { activeOrganizationId?: string } };
}>()
.get("/with-workspace", (c) => {
c.set("session", { activeOrganizationId: workspaceId });
return c.json({ workspaceId: getSessionWorkspaceId(c) });
})
.get("/without-workspace", (c) => {
getSessionWorkspaceId(c);
return c.body(null);
})
.onError(() => new Response(null, { status: STATUS_CODES.INTERNAL_SERVER_ERROR }));

const success = await app.request("/with-workspace");
expect(await success.json()).toEqual({ workspaceId });

const failure = await app.request("/without-workspace");
expect(failure.status).toBe(STATUS_CODES.INTERNAL_SERVER_ERROR);
});

it("creates operational application errors with defaults and details", () => {
const defaultError = new AppError("failed");
const details = { field: faker.database.column() };
const validationError = new AppError("invalid", STATUS_CODES.BAD_REQUEST, details);

expect(defaultError).toMatchObject({
name: "AppError",
message: "failed",
statusCode: STATUS_CODES.INTERNAL_SERVER_ERROR,
isOperational: true,
});
expect(validationError.details).toBe(details);
});
});
126 changes: 126 additions & 0 deletions apps/api/test/lib/http.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import { faker } from "@faker-js/faker";
import { Hono } from "hono";
import { HTTPException } from "hono/http-exception";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { STATUS_CODES } from "@/constants/status-codes.js";
import { AppError } from "@/lib/app-error.js";
import { sendError, sendSuccess } from "@/lib/api-response.js";

const logger = vi.hoisted(() => ({
error: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
}));

vi.mock("@/config/logger.config.js", () => ({ logger }));

import { notFoundHandler, onErrorHandler } from "@/lib/http-handlers.js";

describe("HTTP responses and errors", () => {
beforeEach(() => {
faker.seed(20260706);
});

it("supports success messages and explicit statuses", async () => {
const message = faker.lorem.sentence();
const app = new Hono().get("/", (c) =>
sendSuccess(c, { id: faker.string.uuid() }, STATUS_CODES.CREATED, message),
);

const response = await app.request("/");

expect(response.status).toBe(STATUS_CODES.CREATED);
expect(await response.json()).toEqual({
success: true,
data: { id: expect.any(String) },
message,
});
});

it("returns errors with and without details", async () => {
const app = new Hono()
.get("/simple", (c) => sendError(c, "missing", STATUS_CODES.NOT_FOUND))
.get("/detailed", (c) =>
sendError(c, "invalid", STATUS_CODES.BAD_REQUEST, { field: "name" }),
);

await expect((await app.request("/simple")).json()).resolves.toEqual({
success: false,
error: { message: "missing" },
});
await expect((await app.request("/detailed")).json()).resolves.toEqual({
success: false,
error: { message: "invalid", details: { field: "name" } },
});
});

it("formats not-found responses", async () => {
const app = new Hono().notFound(notFoundHandler);

const response = await app.request("/unknown", { method: "PATCH" });

expect(response.status).toBe(STATUS_CODES.NOT_FOUND);
expect(await response.json()).toMatchObject({
error: { message: "Route PATCH /unknown not found" },
});
});

it.each([
["application", new AppError("invalid", STATUS_CODES.UNPROCESSABLE_ENTITY, "details"), 422],
["HTTP", new HTTPException(403, { message: "forbidden" }), 403],
])("returns operational %s errors", async (_label, error, status) => {
const app = new Hono()
.get("/", () => {
throw error;
})
.onError(onErrorHandler);

const response = await app.request("/");

expect(response.status).toBe(status);
expect(logger.warn).toHaveBeenCalled();
});

it("preserves Better Auth HTTP responses", async () => {
const app = new Hono()
.get("/api/auth/fail", () => {
throw new HTTPException(429, { message: "rate limited" });
})
.onError(onErrorHandler);

const response = await app.request("/api/auth/fail");

expect(response.status).toBe(429);
expect(await response.text()).toBe("rate limited");
});

it("hides unexpected server errors", async () => {
const error = new Error("secret failure");
const app = new Hono()
.get("/", () => {
throw error;
})
.onError(onErrorHandler);

const response = await app.request("/");

expect(response.status).toBe(500);
expect(await response.json()).toMatchObject({
error: { message: "Internal Server Error" },
});
expect(logger.error).toHaveBeenCalled();
});

it("handles unknown non-Error values defensively", async () => {
let context: Parameters<typeof onErrorHandler>[1] | undefined;
const app = new Hono().get("/", (c) => {
context = c;
return c.body(null);
});
await app.request("/");

const response = await onErrorHandler("non-error" as never, context!);

expect(response.status).toBe(500);
});
});
71 changes: 71 additions & 0 deletions apps/api/test/lib/infrastructure.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { Hono } from "hono";
import { beforeEach, describe, expect, it, vi } from "vitest";

const mocks = vi.hoisted(() => ({
send: vi.fn(),
info: vi.fn(),
error: vi.fn(),
}));

vi.mock("resend", () => ({
Resend: class {
emails = { send: mocks.send };
},
}));
vi.mock("@/config/env.config.js", () => ({
env: {
RESEND_API_KEY: "test-key",
RESEND_FROM_EMAIL: "Stallion <test@example.test>",
WEB_URL: "https://web.example.test",
},
}));
vi.mock("@/config/logger.config.js", () => ({
logger: { info: mocks.info, error: mocks.error },
}));

import { corsMiddleware } from "@/lib/cors.js";
import { sendEmail } from "@/lib/email.js";

describe("external infrastructure adapters", () => {
beforeEach(() => {
mocks.send.mockResolvedValue({ data: { id: "email-id" }, error: null });
});

it("sends email through the configured provider", async () => {
await sendEmail({ to: "ada@example.test", subject: "Hello", html: "<p>Hello</p>" });

expect(mocks.send).toHaveBeenCalledWith({
from: "Stallion <test@example.test>",
to: "ada@example.test",
subject: "Hello",
html: "<p>Hello</p>",
});
expect(mocks.info).toHaveBeenCalled();
});

it("logs and throws provider failures", async () => {
mocks.send.mockResolvedValueOnce({ data: null, error: { message: "rejected" } });

await expect(
sendEmail({ to: "ada@example.test", subject: "Hello", html: "<p>Hello</p>" }),
).rejects.toThrow("Failed to send email: rejected");
expect(mocks.error).toHaveBeenCalled();
});

it("applies the configured CORS policy", async () => {
const app = new Hono().use("*", corsMiddleware).get("/", (c) => c.text("ok"));

const response = await app.request("/", {
method: "OPTIONS",
headers: {
origin: "https://web.example.test",
"access-control-request-method": "PATCH",
"access-control-request-headers": "Content-Type,Authorization",
},
});

expect(response.headers.get("access-control-allow-origin")).toBe("https://web.example.test");
expect(response.headers.get("access-control-allow-credentials")).toBe("true");
expect(response.headers.get("access-control-allow-methods")).toContain("PATCH");
});
});
Loading
Loading