Initial commit: Next.js scaffold, Request entity, Prisma+Zod, Tailwind v4+shadcn
- Next.js 14 (TS, App Router) monolith - Request entity (Draft state) via Prisma + Postgres, validated with Zod - ESLint configured - Tailwind v4 + shadcn/ui (new-york-v4 style) - Vitest test suite (6 tests, runs against throwaway Postgres DB per file) - Multi-agent dev workflow docs in CLAUDE.md and .claude/agents/
This commit is contained in:
@@ -0,0 +1,93 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { GET, POST } from "@/app/api/requests/route";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
function postRequest(body: unknown): Request {
|
||||
return new Request("http://localhost/api/requests", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
// Each test starts from an empty table so listing/order assertions are
|
||||
// deterministic.
|
||||
await prisma.request.deleteMany();
|
||||
});
|
||||
|
||||
describe("POST /api/requests", () => {
|
||||
it("creates a request in Draft status on valid input", async () => {
|
||||
const res = await POST(
|
||||
postRequest({ employee_id: "emp-1", type: "asset", reason: "New laptop for onboarding" })
|
||||
);
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
const body = await res.json();
|
||||
|
||||
expect(body).toMatchObject({
|
||||
employee_id: "emp-1",
|
||||
type: "asset",
|
||||
reason: "New laptop for onboarding",
|
||||
status: "Draft",
|
||||
});
|
||||
expect(typeof body.id).toBe("string");
|
||||
expect(body.id.length).toBeGreaterThan(0);
|
||||
expect(typeof body.created_at).toBe("string");
|
||||
expect(Number.isNaN(Date.parse(body.created_at))).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects requests with missing required fields", async () => {
|
||||
const res = await POST(postRequest({ employee_id: "", type: "asset", reason: "" }));
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
const body = await res.json();
|
||||
expect(typeof body.error).toBe("string");
|
||||
expect(body.error.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("rejects requests missing the reason field entirely", async () => {
|
||||
const res = await POST(postRequest({ employee_id: "emp-1", type: "asset" }));
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
const body = await res.json();
|
||||
expect(typeof body.error).toBe("string");
|
||||
});
|
||||
|
||||
it("rejects an invalid type value", async () => {
|
||||
const res = await POST(
|
||||
postRequest({ employee_id: "emp-1", type: "not-a-real-type", reason: "Something" })
|
||||
);
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
const body = await res.json();
|
||||
expect(body.error).toMatch(/type/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/requests", () => {
|
||||
it("lists created requests, newest first", async () => {
|
||||
await POST(postRequest({ employee_id: "emp-1", type: "asset", reason: "First request" }));
|
||||
// Ensure a distinct created_at ordering even on fast filesystems/clocks.
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
await POST(postRequest({ employee_id: "emp-2", type: "license", reason: "Second request" }));
|
||||
|
||||
const res = await GET();
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const body = await res.json();
|
||||
expect(Array.isArray(body)).toBe(true);
|
||||
expect(body).toHaveLength(2);
|
||||
|
||||
// newest first
|
||||
expect(body[0]).toMatchObject({ employee_id: "emp-2", type: "license", status: "Draft" });
|
||||
expect(body[1]).toMatchObject({ employee_id: "emp-1", type: "asset", status: "Draft" });
|
||||
});
|
||||
|
||||
it("returns an empty list when there are no requests", async () => {
|
||||
const res = await GET();
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
// Creates a throwaway Postgres database per test-file run against the
|
||||
// docker-compose `db` service, points DATABASE_URL at it so tests never
|
||||
// touch a shared dev database, and drops it afterwards. Mirrors the old
|
||||
// sqlite temp-file-per-run isolation, adapted to Postgres (CREATE/DROP
|
||||
// DATABASE instead of a throwaway file).
|
||||
//
|
||||
// Creating/dropping the throwaway database itself stays raw `pg` (test
|
||||
// infra, not the app's data-access layer) — but applying the schema to the
|
||||
// fresh database, and tearing down the pooled connection afterwards, now
|
||||
// goes through Prisma instead of the old db.ts.
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { execSync } from "node:child_process";
|
||||
import { Client } from "pg";
|
||||
import { afterAll, beforeAll } from "vitest";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
const baseUrl = new URL(
|
||||
process.env.DATABASE_URL ||
|
||||
"postgres://asset_license_mgmt:change-me-local-dev@db:5432/asset_license_mgmt"
|
||||
);
|
||||
const testDbName = `test_${randomUUID().replace(/-/g, "_")}`;
|
||||
|
||||
function adminUrl(): string {
|
||||
const url = new URL(baseUrl.toString());
|
||||
url.pathname = "/postgres";
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
async function withAdminClient<T>(fn: (client: Client) => Promise<T>): Promise<T> {
|
||||
const client = new Client({ connectionString: adminUrl() });
|
||||
await client.connect();
|
||||
try {
|
||||
return await fn(client);
|
||||
} finally {
|
||||
await client.end();
|
||||
}
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
await withAdminClient((client) => client.query(`CREATE DATABASE ${testDbName}`));
|
||||
|
||||
const testUrl = new URL(baseUrl.toString());
|
||||
testUrl.pathname = `/${testDbName}`;
|
||||
process.env.DATABASE_URL = testUrl.toString();
|
||||
|
||||
// Apply the Prisma migration history to the fresh throwaway database.
|
||||
// `migrate deploy` replays the same real migrations production uses
|
||||
// (prisma/migrations), rather than a schema-diffing shortcut like
|
||||
// `db push` — keeping this test setup honest about what actually runs
|
||||
// outside tests too.
|
||||
execSync("npx prisma migrate deploy", { stdio: "inherit" });
|
||||
}, 30_000);
|
||||
|
||||
afterAll(async () => {
|
||||
// Disconnect the app's Prisma Client singleton first so the admin
|
||||
// connection is free to drop the database (DROP DATABASE ... WITH
|
||||
// (FORCE) would also disconnect it, but closing cleanly first avoids
|
||||
// relying on that).
|
||||
await prisma.$disconnect();
|
||||
await withAdminClient((client) =>
|
||||
client.query(`DROP DATABASE IF EXISTS ${testDbName} WITH (FORCE)`)
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user