diff --git a/apps/web/README.md b/apps/web/README.md index 16d8ffe..055ea5e 100644 --- a/apps/web/README.md +++ b/apps/web/README.md @@ -2,23 +2,25 @@ The user-facing web application. A Next.js server-rendered React app. -**Boilerplate:** the home page shows a welcome message and a health-check link. -Example branches add their own pages, API routes, and Client Components. +The Job Tracker: a landing page, the signed-in user's job list, and a form to +add a job (see `docs/specs/jobs/list.md`). ## Public surface -All public routes are defined under `app/api/`: +Pages live under `app/`, route handlers under `app/api/`: | Route | Method | Purpose | |---|---|---| -| `/` | GET | Home page — boilerplate welcome message | +| `/` | GET | Landing page — links to the job list | +| `/jobs` | GET | The current user's jobs, newest applied first | +| `/jobs/new` | GET | Add-a-job form; submits via a Server Action | | `/api/health` | GET | Health check — is the process alive? Can it reach the DB? | ## Dependencies - `@project/db` — the Prisma client and generated types - `@project/services` — queue, storage, and notify adapters -- `@project/domain` — Zod schemas and database queries (empty on main, populated on example branches) +- `@project/domain` — Zod schemas and database queries - `@project/auth` — the `currentUserId()` dev identity stub - `@project/log` — pino logger diff --git a/apps/web/app/jobs/error.tsx b/apps/web/app/jobs/error.tsx new file mode 100644 index 0000000..318684c --- /dev/null +++ b/apps/web/app/jobs/error.tsx @@ -0,0 +1,16 @@ +"use client"; +// Retry card for /jobs. Shows a generic message — never error.message. +export default function JobsError({ reset }: { error: Error; reset: () => void }) { + return ( +
+

We couldn't load your jobs.

+ +
+ ); +} diff --git a/apps/web/app/jobs/loading.tsx b/apps/web/app/jobs/loading.tsx new file mode 100644 index 0000000..823670e --- /dev/null +++ b/apps/web/app/jobs/loading.tsx @@ -0,0 +1,13 @@ +// Skeleton for /jobs while the list loads. +export default function Loading() { + return ( +
+
+
+ {Array.from({ length: 5 }, (_, i) => ( +
+ ))} +
+
+ ); +} diff --git a/apps/web/app/jobs/new/actions.ts b/apps/web/app/jobs/new/actions.ts new file mode 100644 index 0000000..cdb9f67 --- /dev/null +++ b/apps/web/app/jobs/new/actions.ts @@ -0,0 +1,42 @@ +"use server"; +// Creates a job from the /jobs/new form, following the server entry ritual +// in docs/specs/web.md. +import { revalidatePath } from "next/cache"; +import { redirect } from "next/navigation"; +import { currentUserId } from "@project/auth"; +import { CreateJob, createJob } from "@project/domain"; +import { mapError, type ErrorBody } from "../../../lib/errors"; + +// fieldErrors and values ride beside the error shape; values refill the form, +// which React resets after every action. +export type CreateState = + | (ErrorBody & { + fieldErrors?: Record; + values?: Record; + }) + | null; + +export async function createJobAction(_: CreateState, form: FormData): Promise { + const userId = await currentUserId(); + const values = { + company: String(form.get("company") ?? ""), + title: String(form.get("title") ?? ""), + status: String(form.get("status") ?? ""), + dateApplied: String(form.get("dateApplied") ?? ""), + }; + const parsed = CreateJob.safeParse({ + company: values.company, + title: values.title, + status: values.status || undefined, + dateApplied: values.dateApplied || undefined, + }); + if (!parsed.success) + return { ...mapError(parsed.error).body, fieldErrors: parsed.error.flatten().fieldErrors, values }; + try { + await createJob(userId, parsed.data); + } catch (e) { + return { ...mapError(e).body, values }; + } + revalidatePath("/jobs"); + redirect("/jobs"); // outside try — redirect throws +} diff --git a/apps/web/app/jobs/new/form.tsx b/apps/web/app/jobs/new/form.tsx new file mode 100644 index 0000000..87d0aad --- /dev/null +++ b/apps/web/app/jobs/new/form.tsx @@ -0,0 +1,71 @@ +"use client"; +// The add-a-job form. Field errors render under their input; any other error +// renders once at the top. +import { useActionState } from "react"; +import { createJobAction } from "./actions"; + +const input = "w-full rounded border border-neutral-300 px-3 py-2"; + +function FieldError({ id, messages }: { id: string; messages?: string[] }) { + if (!messages?.length) return null; + return ( +

+ {messages[0]} +

+ ); +} + +// Statuses come from the server page: importing @project/domain here would +// pull the Prisma client into the browser bundle. +export function NewJobForm({ statuses, today }: { statuses: readonly string[]; today: string }) { + const [state, formAction, pending] = useActionState(createJobAction, null); + const fe = state?.fieldErrors ?? {}; + const v = state?.values; + + return ( +
+ {state && !state.fieldErrors && ( +

+ {state.error.message} +

+ )} + +
+ + + +
+ +
+ + + +
+ +
+ + + +
+ +
+ + + +
+ + +
+ ); +} diff --git a/apps/web/app/jobs/new/page.tsx b/apps/web/app/jobs/new/page.tsx new file mode 100644 index 0000000..7013c3e --- /dev/null +++ b/apps/web/app/jobs/new/page.tsx @@ -0,0 +1,21 @@ +// Add-a-job page (docs/specs/jobs/list.md). +import Link from "next/link"; +import { JOB_STATUSES } from "@project/domain"; +import { NewJobForm } from "./form"; + +// Dynamic so the date-applied default is today, not the build date. +export const dynamic = "force-dynamic"; + +export default function NewJobPage() { + return ( +
+
+ + ← Your jobs + +

Add a job

+
+ +
+ ); +} diff --git a/apps/web/app/jobs/page.tsx b/apps/web/app/jobs/page.tsx new file mode 100644 index 0000000..000663b --- /dev/null +++ b/apps/web/app/jobs/page.tsx @@ -0,0 +1,64 @@ +// The current user's jobs, newest applied first (docs/specs/jobs/list.md). +import Link from "next/link"; +import { currentUserId } from "@project/auth"; +import { listJobs } from "@project/domain"; + +export const dynamic = "force-dynamic"; + +// UTC so a date stored at midnight UTC doesn't render as the previous day. +function fmt(d: Date) { + return d.toLocaleDateString("en-US", { timeZone: "UTC" }); +} + +const addLink = ( + + Add a job + +); + +export default async function JobsPage() { + const userId = await currentUserId(); + const jobs = await listJobs(userId); + + return ( +
+
+

Your jobs

+ {addLink} +
+ + {jobs.length === 0 ? ( +
+

No jobs yet. Add the first application you've sent.

+ {addLink} +
+ ) : ( + + + + + + + + + + + + {jobs.map((j) => ( + + + + + + + + ))} + +
CompanyTitleStatusAppliedFollow up
{j.company}{j.title}{j.status}{fmt(j.dateApplied)}{j.followUpDate ? fmt(j.followUpDate) : "—"}
+ )} +
+ ); +} diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx index 9d4cdcd..0175011 100644 --- a/apps/web/app/layout.tsx +++ b/apps/web/app/layout.tsx @@ -4,8 +4,8 @@ import type { Metadata } from "next"; import "./globals.css"; export const metadata: Metadata = { - title: "Starter Skeleton", - description: "C12 Fall 2026 — every layer exists; the weeks make them deep.", + title: "Job Tracker", + description: "Track every job application and what happened next.", }; export default function RootLayout({ children }: { children: React.ReactNode }) { diff --git a/apps/web/app/page.tsx b/apps/web/app/page.tsx index 447101f..ca109da 100644 --- a/apps/web/app/page.tsx +++ b/apps/web/app/page.tsx @@ -1,34 +1,19 @@ -// Boilerplate home page. Shows the current dev user and a link to the health -// check endpoint. Example branches replace this with their own app-specific UI. -import { currentUserId } from "@project/auth"; - -export const dynamic = "force-dynamic"; - -export default async function Home() { - const userId = await currentUserId(); +// Landing page. Points the user at their job list. +import Link from "next/link"; +export default function Home() { return ( -
+
-

Starter Skeleton

-

- Signed in as {userId} (dev - identity stub — real auth arrives in Week 8) -

+

Job Tracker

+

Every application you've sent, and what happened next.

- -
-

- Welcome to the starter skeleton. The database is connected and ready. - Example branches add their own pages, API routes, and background workers here. -

- - Check health → - -
+ + Your jobs → +
); -} \ No newline at end of file +} diff --git a/apps/web/lib/errors.ts b/apps/web/lib/errors.ts new file mode 100644 index 0000000..c9c0e7f --- /dev/null +++ b/apps/web/lib/errors.ts @@ -0,0 +1,19 @@ +// Maps anything thrown at the server boundary to the one error shape +// ({ error: { code, message } }, see docs/specs/web.md). Unknown errors are +// logged here and never reach the client. +import { Prisma } from "@project/db"; +import { ZodError } from "@project/domain"; +import { log } from "@project/log"; + +export type ErrorBody = { error: { code: string; message: string } }; + +export function mapError(err: unknown): { status: number; body: ErrorBody } { + if (err instanceof ZodError) + return { status: 400, body: { error: { code: "VALIDATION", message: err.issues[0]?.message ?? "Invalid input" } } }; + if (err instanceof Prisma.PrismaClientKnownRequestError) { + if (err.code === "P2002") return { status: 409, body: { error: { code: "CONFLICT", message: "Already exists" } } }; + if (err.code === "P2025") return { status: 404, body: { error: { code: "NOT_FOUND", message: "Not found" } } }; + } + log.error({ err }, "unhandled server error"); + return { status: 500, body: { error: { code: "INTERNAL", message: "Something went wrong" } } }; +} diff --git a/docs/specs/README.md b/docs/specs/README.md index 97794f3..67116e2 100644 --- a/docs/specs/README.md +++ b/docs/specs/README.md @@ -30,17 +30,13 @@ store issue state. See [CONTRIBUTING](../../CONTRIBUTING.md) for the full arc. | [deploy](deploy.md) | infrastructure | Container build + Azure topology, config via env only, kill-switches | 0008 | | [observability](observability.md) | infrastructure | Structured logs, request IDs, what gets measured | — | -### Items domain (`items/`) +### Jobs domain (`jobs/`) | Doc | Type | What it covers | Key ADRs | |---|---|---|---| -| [list](items/list.md) | feature | List my items, scoped by user, excludes soft-deleted | 0009, 0003 | -| [create](items/create.md) | feature | Create an item with Zod validation + CREATED event | 0009 | -| [detail](items/detail.md) | feature | View item detail, ownership, lightbox | 0009 | -| [attachment](items/attachment.md) | feature | Upload/download image, blob storage, MIME validation | 0006, 0008 | -| [thumbnail](items/thumbnail.md) | feature | Worker thumbnail generation with sharp, queue pipeline | 0006, 0007, 0008 | -| [live-progress](items/live-progress.md) | feature | SSE live progress for thumbnail pipeline, pg_notify fan-out | 0007, 0005 | +| [data-model](jobs/data-model.md) | feature | `Job` + append-only `JobEvent`; scoping, soft delete, history in one transaction | 0009 | +| [seed](jobs/seed.md) | feature | Believable demo data for `demo-user` | 0003 | +| [list](jobs/list.md) | feature | `/jobs` lists my jobs; `/jobs/new` adds one with validation + `CREATED` event | 0009, 0003 | Feature specs live under `docs/specs//`, grouped by the domain they -describe. In this template repo the `items/` domain is a worked demonstration; -an application built from this template keeps its own domains on `main`. +describe. diff --git a/docs/specs/jobs/data-model.md b/docs/specs/jobs/data-model.md index 6bf33bf..e77db19 100644 --- a/docs/specs/jobs/data-model.md +++ b/docs/specs/jobs/data-model.md @@ -36,7 +36,9 @@ history per job. Stated once, here, so every other spec can lean on it. atomically; a crash between the two leaves neither. - Invalid input never reaches the database: `CreateJob` validates at the boundary (required non-empty company/title, enum status, URL shape, - `salaryMin ≤ salaryMax`, length caps). + `salaryMin ≤ salaryMax`, length caps). Only company and title are + required: `status` defaults to `APPLIED`, `dateApplied` to now, `notes` to + empty, and every other optional field to null. ## Examples diff --git a/docs/specs/jobs/list.md b/docs/specs/jobs/list.md new file mode 100644 index 0000000..aa6693c --- /dev/null +++ b/docs/specs/jobs/list.md @@ -0,0 +1,79 @@ +--- +type: feature +--- +# Signed-in users see their jobs and can add one + +## Why +A job seeker needs to see their whole pipeline in one place and put a new +application into it the moment they send it. This is the first page that +makes the app a Job Tracker rather than a skeleton. + +## Where it lives +- `apps/web/app/page.tsx` — landing page linking to `/jobs` +- `apps/web/app/jobs/page.tsx`, `loading.tsx`, `error.tsx` — the list and its + async states +- `apps/web/app/jobs/new/page.tsx`, `form.tsx`, `actions.ts` — the add form + and its Server Action +- `apps/web/lib/errors.ts` — maps thrown errors to the one error shape +- `packages/domain/src/schemas/job.ts` — `CreateJob` validation +- `packages/domain/src/queries/jobs.ts` — `listJobs`, `createJob` + +## Behavior +- `/jobs` lists the current user's non-deleted jobs, newest `dateApplied` + first. Each row shows company, title, status, date applied, and follow-up + date ("—" when none). Dates render in UTC so they do not shift with the + server's time zone. +- `/jobs` always offers an "Add a job" link. With no jobs it shows an empty + state whose primary action is that link. While loading it shows a skeleton; + on failure it shows a retry card with a generic message — never the raw + error text. +- Jobs belonging to other users never appear; an unknown user sees the empty + state. +- `/jobs/new` requires company and title. Status defaults to `APPLIED` and + date applied defaults to today. +- A valid submit writes the job and its `CREATED` event in one transaction, + then redirects to `/jobs`, where the new job appears. +- An invalid submit writes nothing and re-renders the form with a message + under each invalid field; the action's result is a `VALIDATION` (400) error + in the shape `{ error: { code, message } }`. +- Any other failure maps to the same shape: P2002 → 409 `CONFLICT`, + P2025 → 404 `NOT_FOUND`, anything else → 500 `INTERNAL` with a generic + message; the original error is logged server-side, not returned. +- `/` is a landing page that links to `/jobs`. + +## Examples + +| State / input | Behavior | +|---|---| +| seeded `demo-user` → `/jobs` | 10 rows, newest applied first | +| `x-user-id: someone-else` → `/jobs` | empty state with "Add a job" | +| submit `{company:"Acme", title:"SWE"}` | job with status `APPLIED`, date applied today, one `CREATED` event; appears in the list | +| submit `{company:"", title:"SWE"}` | 400 `VALIDATION`, field message on company, no row written | +| user A creates a job | absent from user B's list | +| unexpected error in the action | 500 `INTERNAL`, generic message, no error text leaked | + +## Verify +- `pnpm test:integration` — `tests/integration/jobs-create.test.ts` and + `jobs-action.test.ts` cover every Example above. +- `pnpm dev` + `pnpm db:seed`, open `/jobs` — 10 seeded rows, newest first. +- `curl -s -H 'x-user-id: someone-else' localhost:3000/jobs | grep -i "no jobs"` + — the empty state. +- Keyboard-only pass on `/jobs/new`: tab through every field and submit with + Enter. + +## Constraints & decisions +- **Adding is a separate page.** `/jobs/new` is a plain form for now; adding + inline from the list is a later change to this spec. +- **Field errors ride beside the error shape.** The action returns + `{ error: { code, message }, fieldErrors }`; `fieldErrors` is additive, so + the one error shape from [web.md](../web.md) is unchanged. +- **The list reads full rows.** `listJobs` has no `select`; narrowing it is + an optimization nothing needs yet. + +## Out of scope +- The health endpoint — owned by [web.md](../web.md). +- Showing each job's latest activity — `docs/specs/jobs/latest-activity.md` + (does not exist yet). +- Inline add from the list, and deeper async states (streamed sections, + optimistic updates) — no spec owns these yet. +- Real authentication — [auth.md](../auth.md). diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts index 7cf81ec..01d91c8 100644 --- a/packages/db/src/index.ts +++ b/packages/db/src/index.ts @@ -2,7 +2,7 @@ // Apps and other packages import from @project/db, never from @prisma/client directly. // The migration runner (applyMigrations) is available at @project/db/migrate. export { prisma, LOCAL_DEV_URL } from "./client"; -export { PrismaClient } from "./generated/prisma"; +export { PrismaClient, Prisma } from "./generated/prisma"; export type { Job, JobEvent, diff --git a/packages/domain/src/index.ts b/packages/domain/src/index.ts index c43e1ef..810e1b8 100644 --- a/packages/domain/src/index.ts +++ b/packages/domain/src/index.ts @@ -7,3 +7,5 @@ export { JOB_SOURCES, } from "./schemas/job"; export { listJobs, getJob, createJob } from "./queries/jobs"; +// Re-exported so web can recognize validation errors without depending on zod. +export { ZodError } from "zod"; diff --git a/packages/domain/src/schemas/job.ts b/packages/domain/src/schemas/job.ts index 685905c..b60ec9d 100644 --- a/packages/domain/src/schemas/job.ts +++ b/packages/domain/src/schemas/job.ts @@ -37,13 +37,15 @@ export const CreateJob = z company: z.string().trim().min(1, "Company is required").max(200), title: z.string().trim().min(1, "Title is required").max(200), status: z.enum(JOB_STATUSES).default("APPLIED"), - dateApplied: z.coerce.date(), - followUpDate: z.coerce.date().nullable(), - source: z.enum(JOB_SOURCES).nullable(), - url: z.string().url().max(2000).nullable(), - location: z.string().trim().max(200).nullable(), - salaryMin: z.number().int().nonnegative().nullable(), - salaryMax: z.number().int().nonnegative().nullable(), + dateApplied: z.coerce.date().default(() => new Date()), + // .nullable().default(null), not .optional(): the key is optional on input + // but the output stays `T | null`, which keeps CreateJobMatchesPrisma true. + followUpDate: z.coerce.date().nullable().default(null), + source: z.enum(JOB_SOURCES).nullable().default(null), + url: z.string().url().max(2000).nullable().default(null), + location: z.string().trim().max(200).nullable().default(null), + salaryMin: z.number().int().nonnegative().nullable().default(null), + salaryMax: z.number().int().nonnegative().nullable().default(null), notes: z.string().max(5000).optional().default(""), }) .refine( diff --git a/tests/integration/README.md b/tests/integration/README.md index 9184cd4..146ffa3 100644 --- a/tests/integration/README.md +++ b/tests/integration/README.md @@ -6,7 +6,10 @@ End-to-end tests against a real Postgres engine running in-process via PGlite | File | What it tests | |---|---| | `smoke.test.ts` | Boilerplate — verifies the PGlite door connects, inherited from `main` | -| `items.test.ts` | Items example — creates items, verifies `CREATED` events, scoping, soft-delete filtering | +| `jobs.test.ts` | Data model — `CREATED` events, scoping, soft-delete filtering, boundary validation | +| `jobs-action.test.ts` | The `/jobs/new` Server Action — redirect + revalidate on success, field errors on rejection, 500 on a db failure | +| `jobs-create.test.ts` | Jobs list + add — every Example in `docs/specs/jobs/list.md`, plus the error mapping | +| `health.test.ts` | `GET /api/health` — 200 with the db up, 503 `db: "error"` when it is unreachable | **Note**: PGlite does not support `pg_notify`. SSE tests require a real Postgres connection or mocking. \ No newline at end of file diff --git a/tests/integration/health.test.ts b/tests/integration/health.test.ts new file mode 100644 index 0000000..b88f569 --- /dev/null +++ b/tests/integration/health.test.ts @@ -0,0 +1,31 @@ +import { describe, it, expect, beforeAll, vi } from "vitest"; + +// GET /api/health against in-memory PGlite (docs/specs/web.md § Health endpoint). + +let GET: () => Promise; + +beforeAll(async () => { + process.env.PGLITE_DATA_DIR = "memory://"; + delete process.env.DATABASE_URL; + ({ GET } = await import("../../apps/web/app/api/health/route")); +}, 30000); + +describe("GET /api/health", () => { + it("returns 200 ok when the database answers", async () => { + const res = await GET(); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ status: "ok", db: "ok" }); + }); + + it("returns 503 db:error when the database is unreachable", async () => { + const { prisma } = await import("@project/db"); + const spy = vi.spyOn(prisma, "$queryRaw").mockRejectedValueOnce(new Error("down")); + try { + const res = await GET(); + expect(res.status).toBe(503); + expect(await res.json()).toEqual({ status: "ok", db: "error" }); + } finally { + spy.mockRestore(); + } + }); +}); diff --git a/tests/integration/jobs-action.test.ts b/tests/integration/jobs-action.test.ts new file mode 100644 index 0000000..b52bf80 --- /dev/null +++ b/tests/integration/jobs-action.test.ts @@ -0,0 +1,77 @@ +import { describe, it, expect, beforeAll, beforeEach, vi } from "vitest"; + +// The /jobs/new Server Action end to end against PGlite, with only Next's +// navigation helpers and the identity stub mocked (docs/specs/jobs/list.md). + +const nav = vi.hoisted(() => ({ + user: "action-user", + redirect: vi.fn((to: string) => { + throw new Error(`REDIRECT ${to}`); + }), + revalidatePath: vi.fn(), +})); +// Mocked by path: `next` and `@project/auth` are dependencies of apps/web, +// not of the repo root, so their bare names don't resolve from here. +vi.mock("../../apps/web/node_modules/next/navigation.js", () => ({ redirect: nav.redirect })); +vi.mock("../../apps/web/node_modules/next/cache.js", () => ({ revalidatePath: nav.revalidatePath })); +vi.mock("../../packages/auth/src/index.ts", () => ({ currentUserId: async () => nav.user })); + +type Action = typeof import("../../apps/web/app/jobs/new/actions").createJobAction; +let createJobAction: Action; + +beforeAll(async () => { + process.env.PGLITE_DATA_DIR = "memory://"; + delete process.env.DATABASE_URL; + const { prisma } = await import("@project/db"); + await prisma.user.create({ data: { id: "action-user", name: "Action" } }); + ({ createJobAction } = await import("../../apps/web/app/jobs/new/actions")); +}, 30000); + +beforeEach(() => { + nav.redirect.mockClear(); + nav.revalidatePath.mockClear(); +}); + +function form(fields: Record) { + const f = new FormData(); + for (const [k, v] of Object.entries(fields)) f.set(k, v); + return f; +} + +describe("createJobAction", () => { + it("creates the job for the current user, revalidates /jobs, and redirects there", async () => { + const { listJobs } = await import("@project/domain"); + + await expect(createJobAction(null, form({ company: "Acme", title: "SWE" }))).rejects.toThrow( + "REDIRECT /jobs" + ); + + expect(nav.revalidatePath).toHaveBeenCalledWith("/jobs"); + const [job] = (await listJobs("action-user")).filter((j) => j.company === "Acme"); + expect(job?.status).toBe("APPLIED"); + }); + + it("returns VALIDATION with field errors and the submitted values, writing nothing", async () => { + const { prisma } = await import("@project/db"); + const before = await prisma.job.count(); + + const state = await createJobAction(null, form({ company: "", title: "SWE", status: "APPLIED" })); + + expect(state?.error.code).toBe("VALIDATION"); + expect(state?.fieldErrors?.company).toEqual(["Company is required"]); + expect(state?.values).toMatchObject({ company: "", title: "SWE" }); + expect(nav.redirect).not.toHaveBeenCalled(); + expect(await prisma.job.count()).toBe(before); + }); + + it("maps a database failure to 500 INTERNAL without leaking it", async () => { + nav.user = "no-such-user"; // FK violation on Job.userId + try { + const state = await createJobAction(null, form({ company: "Acme", title: "SWE" })); + expect(state?.error).toEqual({ code: "INTERNAL", message: "Something went wrong" }); + expect(nav.redirect).not.toHaveBeenCalled(); + } finally { + nav.user = "action-user"; + } + }); +}); diff --git a/tests/integration/jobs-create.test.ts b/tests/integration/jobs-create.test.ts new file mode 100644 index 0000000..9dc0e8d --- /dev/null +++ b/tests/integration/jobs-create.test.ts @@ -0,0 +1,91 @@ +import { describe, it, expect, beforeAll } from "vitest"; + +// The Examples in docs/specs/jobs/list.md: adding a job through the same +// validate → create path the /jobs/new Server Action uses, plus the error +// mapping that turns failures into { error: { code, message } }. + +beforeAll(async () => { + process.env.PGLITE_DATA_DIR = "memory://"; + delete process.env.DATABASE_URL; + + const { prisma } = await import("@project/db"); + await prisma.user.create({ data: { id: "user-a", name: "A" } }); + await prisma.user.create({ data: { id: "user-b", name: "B" } }); +}, 30000); + +describe("adding a job", () => { + it("creates an APPLIED job dated today with one CREATED event, and lists it", async () => { + const { CreateJob, createJob, listJobs } = await import("@project/domain"); + const { prisma } = await import("@project/db"); + + const job = await createJob("user-a", CreateJob.parse({ company: "Acme", title: "SWE" })); + + expect(job.status).toBe("APPLIED"); + expect(job.dateApplied.toISOString().slice(0, 10)).toBe(new Date().toISOString().slice(0, 10)); + const events = await prisma.jobEvent.findMany({ where: { jobId: job.id } }); + expect(events.map((e) => e.type)).toEqual(["CREATED"]); + expect((await listJobs("user-a")).map((j) => j.id)).toContain(job.id); + }); + + it("rejects an empty company as 400 VALIDATION and writes nothing", async () => { + const { CreateJob } = await import("@project/domain"); + const { prisma } = await import("@project/db"); + const { mapError } = await import("../../apps/web/lib/errors"); + const before = await prisma.job.count(); + + const parsed = CreateJob.safeParse({ company: "", title: "SWE" }); + + expect(parsed.success).toBe(false); + if (parsed.success) return; + expect(parsed.error.flatten().fieldErrors.company).toEqual(["Company is required"]); + expect(mapError(parsed.error)).toEqual({ + status: 400, + body: { error: { code: "VALIDATION", message: "Company is required" } }, + }); + expect(await prisma.job.count()).toBe(before); + }); + + it("keeps user A's new job out of user B's list", async () => { + const { CreateJob, createJob, listJobs } = await import("@project/domain"); + + const job = await createJob("user-a", CreateJob.parse({ company: "Private Co", title: "SWE" })); + + expect((await listJobs("user-b")).map((j) => j.id)).not.toContain(job.id); + }); + + it("lists newest dateApplied first", async () => { + const { CreateJob, createJob, listJobs } = await import("@project/domain"); + + await createJob("user-b", CreateJob.parse({ company: "Old", title: "X", dateApplied: "2026-01-01" })); + await createJob("user-b", CreateJob.parse({ company: "New", title: "X", dateApplied: "2026-03-01" })); + + expect((await listJobs("user-b")).map((j) => j.company)).toEqual(["New", "Old"]); + }); +}); + +describe("mapError", () => { + it("maps Prisma P2002 to 409 and P2025 to 404", async () => { + const { Prisma } = await import("@project/db"); + const { mapError } = await import("../../apps/web/lib/errors"); + const known = (code: string) => + new Prisma.PrismaClientKnownRequestError("boom", { code, clientVersion: "test" }); + + expect(mapError(known("P2002"))).toEqual({ + status: 409, + body: { error: { code: "CONFLICT", message: "Already exists" } }, + }); + expect(mapError(known("P2025"))).toEqual({ + status: 404, + body: { error: { code: "NOT_FOUND", message: "Not found" } }, + }); + }); + + it("maps anything else to 500 without leaking the error text", async () => { + const { mapError } = await import("../../apps/web/lib/errors"); + + const { status, body } = mapError(new Error("password=hunter2 at db.internal:5432")); + + expect(status).toBe(500); + expect(body).toEqual({ error: { code: "INTERNAL", message: "Something went wrong" } }); + }); +}); diff --git a/tests/integration/jobs.test.ts b/tests/integration/jobs.test.ts index da162d8..b07734a 100644 --- a/tests/integration/jobs.test.ts +++ b/tests/integration/jobs.test.ts @@ -12,19 +12,14 @@ beforeAll(async () => { const { prisma } = await import("@project/db"); await prisma.user.create({ data: { id: "test-user", name: "Test User" } }); await prisma.user.create({ data: { id: "other-user", name: "Somebody Else" } }); -}, 5000); +}, 30000); describe("promise 3 — history rides the change", () => { it("creates a job WITH its CREATED event, transactionally", async () => { - const { createJob } = await import("@project/domain"); + const { CreateJob, createJob } = await import("@project/domain"); const { prisma } = await import("@project/db"); - const job = await createJob("test-user", { - company: "Datadog", - title: "Software Engineer", - status: "APPLIED", - notes: "", - }); + const job = await createJob("test-user", CreateJob.parse({ company: "Datadog", title: "Software Engineer" })); expect(job.status).toBe("APPLIED"); const events = await prisma.jobEvent.findMany({ where: { jobId: job.id } }); @@ -36,9 +31,9 @@ describe("promise 3 — history rides the change", () => { describe("promise 1 — every query is scoped", () => { it("lists only the requesting user's jobs", async () => { - const { createJob, listJobs } = await import("@project/domain"); + const { CreateJob, createJob, listJobs } = await import("@project/domain"); - await createJob("other-user", { company: "NotYours Inc", title: "Theirs", status: "APPLIED", notes: "" }); + await createJob("other-user", CreateJob.parse({ company: "NotYours Inc", title: "Theirs" })); const mine = await listJobs("test-user"); expect(mine.map((j) => j.company)).toContain("Datadog"); @@ -46,9 +41,9 @@ describe("promise 1 — every query is scoped", () => { }); it("treats a foreign job id exactly like a missing one", async () => { - const { createJob, getJob } = await import("@project/domain"); + const { CreateJob, createJob, getJob } = await import("@project/domain"); - const theirs = await createJob("other-user", { company: "Ramp", title: "SWE", status: "APPLIED", notes: "" }); + const theirs = await createJob("other-user", CreateJob.parse({ company: "Ramp", title: "SWE" })); expect(await getJob(theirs.id, "test-user")).toBeNull(); expect(await getJob("no-such-id", "test-user")).toBeNull(); }); @@ -56,10 +51,10 @@ describe("promise 1 — every query is scoped", () => { describe("promise 2 — soft delete hides, never destroys", () => { it("hides soft-deleted jobs from list and get", async () => { - const { createJob, listJobs, getJob } = await import("@project/domain"); + const { CreateJob, createJob, listJobs, getJob } = await import("@project/domain"); const { prisma } = await import("@project/db"); - const doomed = await createJob("test-user", { company: "Doomed Co", title: "Ghost", status: "APPLIED", notes: "" }); + const doomed = await createJob("test-user", CreateJob.parse({ company: "Doomed Co", title: "Ghost" })); await prisma.job.update({ where: { id: doomed.id }, data: { deletedAt: new Date() } }); expect((await listJobs("test-user")).map((j) => j.company)).not.toContain("Doomed Co");