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:
2026-07-30 18:14:36 +07:00
commit afdbdbdcfb
30 changed files with 13950 additions and 0 deletions
+93
View File
@@ -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([]);
});
});
+63
View File
@@ -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)`)
);
});