From 4328ada5954cf5e23b279463d4f65e38a29d9dd7 Mon Sep 17 00:00:00 2001 From: Matiss Jurevics Date: Tue, 3 Mar 2026 14:25:43 +0000 Subject: [PATCH] added repo --- .dockerignore | 3 + Dockerfile | 16 + README.md | 170 ++++++++++ compose.yml | 12 + data/mock-task-manager.sqlite | Bin 0 -> 77824 bytes package.json | 12 + scripts/seed.ts | 10 + src/config.ts | 19 ++ src/database.ts | 526 +++++++++++++++++++++++++++++++ src/index.ts | 223 +++++++++++++ src/openapi.ts | 571 ++++++++++++++++++++++++++++++++++ src/repository.ts | 346 ++++++++++++++++++++ tests/api.test.ts | 124 ++++++++ tests/repository.test.ts | 73 +++++ tsconfig.json | 13 + 15 files changed, 2118 insertions(+) create mode 100644 .dockerignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 compose.yml create mode 100644 data/mock-task-manager.sqlite create mode 100644 package.json create mode 100644 scripts/seed.ts create mode 100644 src/config.ts create mode 100644 src/database.ts create mode 100644 src/index.ts create mode 100644 src/openapi.ts create mode 100644 src/repository.ts create mode 100644 tests/api.test.ts create mode 100644 tests/repository.test.ts create mode 100644 tsconfig.json diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..2ecd6f6 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,3 @@ +data +node_modules +coverage diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..7bfdb8b --- /dev/null +++ b/Dockerfile @@ -0,0 +1,16 @@ +FROM oven/bun:1.3.5-alpine + +WORKDIR /app + +COPY package.json tsconfig.json ./ +COPY src ./src +COPY scripts ./scripts + +RUN mkdir -p /app/data + +ENV PORT=3010 +ENV DB_PATH=/app/data/mock-task-manager.sqlite + +EXPOSE 3010 + +CMD ["bun", "src/index.ts"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..20604d0 --- /dev/null +++ b/README.md @@ -0,0 +1,170 @@ +# POC Mock Task API + +This folder contains a standalone mock API for a multi-organization task manager. It uses a local SQLite database file and comes pre-seeded with realistic mock data for organizations, users, projects, tasks, labels, and comments. + +## What It Includes + +- Bun HTTP server with no external runtime dependencies +- local SQLite database file at `data/mock-task-manager.sqlite` +- deterministic seed data for three organizations +- task, project, user, and org summary endpoints +- write endpoints for creating tasks and updating task state +- a small test suite covering seeded reads and task mutations +- OpenAPI 3.1 document endpoints for import tooling +- standalone Docker and Compose files for running the mock API by itself + +## Domain Model + +The mock API is structured around a typical B2B task-management product: + +- `organizations`: tenant records such as Acme Logistics and Northstar Health +- `users`: org-scoped staff members with roles and timezones +- `projects`: org-owned projects with an owner and delivery target +- `tasks`: work items linked to an org and project, optionally assigned to a user +- `labels`: org-scoped tags like `backend`, `mobile`, and `compliance` +- `task_labels`: many-to-many label assignments for tasks +- `comments`: task discussion entries authored by users + +## Seeded Data + +The database is seeded with: + +- 3 organizations +- 6 users +- 4 projects +- 7 tasks +- 4 labels +- 3 comments + +The data is intentionally uneven across orgs so consumers can test: + +- different organization sizes +- unassigned tasks +- blocked tasks +- urgent tasks +- org-specific filtering +- detail views with labels and threaded comments + +## API Surface + +### Health + +- `GET /health` +- `GET /openapi.json` +- `GET /swagger.json` +- `GET /api-docs` + +### Organizations + +- `GET /orgs` +- `GET /orgs/:orgId` +- `GET /orgs/:orgId/projects` +- `GET /orgs/:orgId/tasks` + +### Users and Projects + +- `GET /users` +- `GET /users?orgId=org_acme` +- `GET /projects` +- `GET /projects?orgId=org_northstar` + +### Tasks + +- `GET /tasks` +- `GET /tasks?orgId=org_acme&status=todo` +- `GET /tasks/:taskId` +- `POST /tasks` +- `PATCH /tasks/:taskId` + +## Request Examples + +Create a task: + +```bash +curl -X POST http://localhost:3010/tasks \ + -H "Content-Type: application/json" \ + -d '{ + "orgId": "org_acme", + "projectId": "proj_acme_ops", + "assigneeUserId": "user_acme_2", + "title": "Create export audit dashboard", + "description": "Expose failed exports by warehouse and day.", + "priority": "high", + "storyPoints": 5, + "dueDate": "2026-03-19" + }' +``` + +Update a task: + +```bash +curl -X PATCH http://localhost:3010/tasks/task_1001 \ + -H "Content-Type: application/json" \ + -d '{ + "status": "done", + "assigneeUserId": null + }' +``` + +## How To Run + +Seed the database file: + +```bash +bun run db:seed +``` + +Start the server: + +```bash +bun run start +``` + +Run in watch mode: + +```bash +bun run dev +``` + +Run tests: + +```bash +bun test +``` + +Run with Docker Compose from this folder: + +```bash +docker compose up --build +``` + +## Files That Matter + +- `src/index.ts`: HTTP routes and server startup +- `src/openapi.ts`: importable OpenAPI 3.1 document +- `src/database.ts`: schema creation and seed data +- `src/repository.ts`: query and mutation helpers +- `scripts/seed.ts`: resets and reseeds the SQLite file +- `tests/repository.test.ts`: repository-level smoke coverage +- `tests/api.test.ts`: HTTP contract and OpenAPI coverage + +## What This Mock API Needs To Be Useful + +For a task-manager mock API to actually help development, it needs more than one table and one endpoint. The important pieces are: + +- tenant-aware data so frontend and backend code can test org-specific filters +- realistic relationships between orgs, users, projects, tasks, comments, and labels +- both list and detail endpoints, because dashboards and detail pages query differently +- mutable endpoints, so forms and optimistic updates can be tested +- predictable seed data, so demos and tests do not drift +- a local file-backed database, so the service is portable and disposable +- lightweight setup, so anyone can run it without standing up Postgres or external auth + +## Current Limitations + +- no authentication or authorization layer +- no pagination +- no delete endpoints +- no file attachments or activity feed + +Those are reasonable next additions if you want to use this mock API as part of the root compose stack. diff --git a/compose.yml b/compose.yml new file mode 100644 index 0000000..7c84fa6 --- /dev/null +++ b/compose.yml @@ -0,0 +1,12 @@ +services: + mock-task-api: + build: + context: . + dockerfile: Dockerfile + environment: + PORT: 3010 + DB_PATH: /app/data/mock-task-manager.sqlite + ports: + - "3010:3010" + volumes: + - ./data:/app/data diff --git a/data/mock-task-manager.sqlite b/data/mock-task-manager.sqlite new file mode 100644 index 0000000000000000000000000000000000000000..0b761e853ce542ddfbef75013f05dd183017edb8 GIT binary patch literal 77824 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 N{{=D6|6dT3{{u@dbq@dl literal 0 HcmV?d00001 diff --git a/package.json b/package.json new file mode 100644 index 0000000..d6ca76f --- /dev/null +++ b/package.json @@ -0,0 +1,12 @@ +{ + "name": "poc-mock-task-api", + "module": "src/index.ts", + "type": "module", + "private": true, + "scripts": { + "dev": "bun --watch src/index.ts", + "start": "bun src/index.ts", + "db:seed": "bun scripts/seed.ts", + "test": "bun test" + } +} diff --git a/scripts/seed.ts b/scripts/seed.ts new file mode 100644 index 0000000..119cc6a --- /dev/null +++ b/scripts/seed.ts @@ -0,0 +1,10 @@ +import { getConfig } from "../src/config"; +import { reseedDatabase } from "../src/database"; + +const config = getConfig(); +const db = reseedDatabase(config.dbPath); +db.close(); + +console.info("mock-task-api:seeded", { + dbPath: config.dbPath, +}); diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..2b8e370 --- /dev/null +++ b/src/config.ts @@ -0,0 +1,19 @@ +import { resolve } from "node:path"; + +const defaultDbPath = resolve(process.cwd(), "data", "mock-task-manager.sqlite"); + +function parsePort(value?: string): number { + const port = Number.parseInt(value ?? "3010", 10); + if (!Number.isInteger(port) || port <= 0) { + throw new Error(`Invalid PORT value: ${value}`); + } + + return port; +} + +export function getConfig(env: NodeJS.ProcessEnv = process.env) { + return { + port: parsePort(env.PORT), + dbPath: env.DB_PATH?.trim() || defaultDbPath, + }; +} diff --git a/src/database.ts b/src/database.ts new file mode 100644 index 0000000..8a41fd4 --- /dev/null +++ b/src/database.ts @@ -0,0 +1,526 @@ +import { existsSync, mkdirSync, rmSync } from "node:fs"; +import { dirname } from "node:path"; +import { Database } from "bun:sqlite"; + +export interface Organization { + id: string; + slug: string; + name: string; + plan: string; + industry: string; + createdAt: string; +} + +export interface User { + id: string; + orgId: string; + fullName: string; + email: string; + role: string; + timezone: string; +} + +export interface Project { + id: string; + orgId: string; + key: string; + name: string; + status: string; + ownerUserId: string; + dueDate: string | null; +} + +export interface TaskRecord { + id: string; + orgId: string; + projectId: string; + assigneeUserId: string | null; + title: string; + description: string; + status: string; + priority: string; + storyPoints: number | null; + dueDate: string | null; + createdAt: string; + updatedAt: string; +} + +export interface Label { + id: string; + orgId: string; + name: string; + color: string; +} + +export interface CommentRecord { + id: string; + taskId: string; + authorUserId: string; + body: string; + createdAt: string; +} + +const organizations: Organization[] = [ + { + id: "org_acme", + slug: "acme-logistics", + name: "Acme Logistics", + plan: "enterprise", + industry: "Logistics", + createdAt: "2026-01-03T09:00:00.000Z", + }, + { + id: "org_northstar", + slug: "northstar-health", + name: "Northstar Health", + plan: "growth", + industry: "Healthcare", + createdAt: "2026-01-11T10:15:00.000Z", + }, + { + id: "org_summit", + slug: "summit-edu", + name: "Summit Education", + plan: "starter", + industry: "Education", + createdAt: "2026-01-20T08:30:00.000Z", + }, +]; + +const users: User[] = [ + { + id: "user_acme_1", + orgId: "org_acme", + fullName: "Maya Patel", + email: "maya@acme.example", + role: "org_admin", + timezone: "Europe/Dublin", + }, + { + id: "user_acme_2", + orgId: "org_acme", + fullName: "Jonas Weber", + email: "jonas@acme.example", + role: "project_manager", + timezone: "Europe/Berlin", + }, + { + id: "user_acme_3", + orgId: "org_acme", + fullName: "Elena Rossi", + email: "elena@acme.example", + role: "engineer", + timezone: "Europe/Rome", + }, + { + id: "user_northstar_1", + orgId: "org_northstar", + fullName: "Samir Khan", + email: "samir@northstar.example", + role: "org_admin", + timezone: "America/New_York", + }, + { + id: "user_northstar_2", + orgId: "org_northstar", + fullName: "Leah Morris", + email: "leah@northstar.example", + role: "analyst", + timezone: "America/Chicago", + }, + { + id: "user_summit_1", + orgId: "org_summit", + fullName: "Owen Doyle", + email: "owen@summit.example", + role: "org_admin", + timezone: "Europe/Dublin", + }, +]; + +const projects: Project[] = [ + { + id: "proj_acme_ops", + orgId: "org_acme", + key: "OPS", + name: "Operations Platform", + status: "active", + ownerUserId: "user_acme_2", + dueDate: "2026-04-30", + }, + { + id: "proj_acme_mobile", + orgId: "org_acme", + key: "MOB", + name: "Courier Mobile App", + status: "active", + ownerUserId: "user_acme_1", + dueDate: "2026-05-20", + }, + { + id: "proj_northstar_portal", + orgId: "org_northstar", + key: "PAT", + name: "Patient Intake Portal", + status: "active", + ownerUserId: "user_northstar_1", + dueDate: "2026-05-07", + }, + { + id: "proj_summit_reporting", + orgId: "org_summit", + key: "REP", + name: "Teacher Reporting", + status: "planning", + ownerUserId: "user_summit_1", + dueDate: "2026-06-12", + }, +]; + +const tasks: TaskRecord[] = [ + { + id: "task_1001", + orgId: "org_acme", + projectId: "proj_acme_ops", + assigneeUserId: "user_acme_3", + title: "Add delivery exception workflow", + description: "Support driver-reported delivery exceptions with customer-visible status updates.", + status: "in_progress", + priority: "high", + storyPoints: 8, + dueDate: "2026-03-10", + createdAt: "2026-03-01T09:00:00.000Z", + updatedAt: "2026-03-03T10:15:00.000Z", + }, + { + id: "task_1002", + orgId: "org_acme", + projectId: "proj_acme_ops", + assigneeUserId: "user_acme_2", + title: "Reconcile warehouse inventory feed", + description: "Normalize nightly inventory import and surface failures in the admin dashboard.", + status: "todo", + priority: "urgent", + storyPoints: 5, + dueDate: "2026-03-06", + createdAt: "2026-03-01T12:30:00.000Z", + updatedAt: "2026-03-02T16:00:00.000Z", + }, + { + id: "task_1003", + orgId: "org_acme", + projectId: "proj_acme_mobile", + assigneeUserId: "user_acme_3", + title: "Implement offline proof-of-delivery queue", + description: "Queue signatures and photos when couriers lose connectivity.", + status: "blocked", + priority: "high", + storyPoints: 13, + dueDate: "2026-03-18", + createdAt: "2026-02-25T08:00:00.000Z", + updatedAt: "2026-03-03T09:10:00.000Z", + }, + { + id: "task_1004", + orgId: "org_acme", + projectId: "proj_acme_mobile", + assigneeUserId: null, + title: "Refresh courier onboarding copy", + description: "Rewrite first-run guidance for new regional couriers.", + status: "todo", + priority: "medium", + storyPoints: 2, + dueDate: "2026-03-21", + createdAt: "2026-03-02T14:20:00.000Z", + updatedAt: "2026-03-02T14:20:00.000Z", + }, + { + id: "task_2001", + orgId: "org_northstar", + projectId: "proj_northstar_portal", + assigneeUserId: "user_northstar_2", + title: "Audit intake form validation gaps", + description: "Compare API validation with product requirements for intake submissions.", + status: "in_review", + priority: "high", + storyPoints: 3, + dueDate: "2026-03-12", + createdAt: "2026-03-01T11:00:00.000Z", + updatedAt: "2026-03-03T13:00:00.000Z", + }, + { + id: "task_2002", + orgId: "org_northstar", + projectId: "proj_northstar_portal", + assigneeUserId: "user_northstar_1", + title: "Prepare HIPAA access log export", + description: "Generate downloadable audit log extracts for compliance reviews.", + status: "todo", + priority: "urgent", + storyPoints: 8, + dueDate: "2026-03-08", + createdAt: "2026-02-28T10:45:00.000Z", + updatedAt: "2026-03-02T18:30:00.000Z", + }, + { + id: "task_3001", + orgId: "org_summit", + projectId: "proj_summit_reporting", + assigneeUserId: "user_summit_1", + title: "Define grade export CSV format", + description: "Align export columns with district reporting requirements.", + status: "todo", + priority: "medium", + storyPoints: 3, + dueDate: "2026-03-25", + createdAt: "2026-03-02T09:30:00.000Z", + updatedAt: "2026-03-02T09:30:00.000Z", + }, +]; + +const labels: Label[] = [ + { id: "label_backend", orgId: "org_acme", name: "backend", color: "#0f766e" }, + { id: "label_mobile", orgId: "org_acme", name: "mobile", color: "#1d4ed8" }, + { id: "label_compliance", orgId: "org_northstar", name: "compliance", color: "#b45309" }, + { id: "label_reporting", orgId: "org_summit", name: "reporting", color: "#7c3aed" }, +]; + +const taskLabels = [ + { taskId: "task_1001", labelId: "label_backend" }, + { taskId: "task_1002", labelId: "label_backend" }, + { taskId: "task_1003", labelId: "label_mobile" }, + { taskId: "task_2002", labelId: "label_compliance" }, + { taskId: "task_3001", labelId: "label_reporting" }, +]; + +const comments: CommentRecord[] = [ + { + id: "comment_1", + taskId: "task_1001", + authorUserId: "user_acme_2", + body: "Driver ops needs an exception code for failed building access.", + createdAt: "2026-03-03T09:30:00.000Z", + }, + { + id: "comment_2", + taskId: "task_1003", + authorUserId: "user_acme_1", + body: "Blocked until we settle image retention policy for offline uploads.", + createdAt: "2026-03-03T09:40:00.000Z", + }, + { + id: "comment_3", + taskId: "task_2002", + authorUserId: "user_northstar_2", + body: "Compliance requested a CSV and JSON version of the export.", + createdAt: "2026-03-02T17:15:00.000Z", + }, +]; + +function createSchema(db: Database) { + db.exec(` + PRAGMA foreign_keys = ON; + + CREATE TABLE IF NOT EXISTS organizations ( + id TEXT PRIMARY KEY, + slug TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + plan TEXT NOT NULL, + industry TEXT NOT NULL, + created_at TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY, + org_id TEXT NOT NULL, + full_name TEXT NOT NULL, + email TEXT NOT NULL UNIQUE, + role TEXT NOT NULL, + timezone TEXT NOT NULL, + FOREIGN KEY (org_id) REFERENCES organizations(id) ON DELETE CASCADE + ); + + CREATE TABLE IF NOT EXISTS projects ( + id TEXT PRIMARY KEY, + org_id TEXT NOT NULL, + key TEXT NOT NULL, + name TEXT NOT NULL, + status TEXT NOT NULL, + owner_user_id TEXT NOT NULL, + due_date TEXT, + FOREIGN KEY (org_id) REFERENCES organizations(id) ON DELETE CASCADE, + FOREIGN KEY (owner_user_id) REFERENCES users(id), + UNIQUE (org_id, key) + ); + + CREATE TABLE IF NOT EXISTS tasks ( + id TEXT PRIMARY KEY, + org_id TEXT NOT NULL, + project_id TEXT NOT NULL, + assignee_user_id TEXT, + title TEXT NOT NULL, + description TEXT NOT NULL, + status TEXT NOT NULL, + priority TEXT NOT NULL, + story_points INTEGER, + due_date TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + FOREIGN KEY (org_id) REFERENCES organizations(id) ON DELETE CASCADE, + FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE, + FOREIGN KEY (assignee_user_id) REFERENCES users(id) + ); + + CREATE TABLE IF NOT EXISTS labels ( + id TEXT PRIMARY KEY, + org_id TEXT NOT NULL, + name TEXT NOT NULL, + color TEXT NOT NULL, + FOREIGN KEY (org_id) REFERENCES organizations(id) ON DELETE CASCADE, + UNIQUE (org_id, name) + ); + + CREATE TABLE IF NOT EXISTS task_labels ( + task_id TEXT NOT NULL, + label_id TEXT NOT NULL, + PRIMARY KEY (task_id, label_id), + FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE, + FOREIGN KEY (label_id) REFERENCES labels(id) ON DELETE CASCADE + ); + + CREATE TABLE IF NOT EXISTS comments ( + id TEXT PRIMARY KEY, + task_id TEXT NOT NULL, + author_user_id TEXT NOT NULL, + body TEXT NOT NULL, + created_at TEXT NOT NULL, + FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE, + FOREIGN KEY (author_user_id) REFERENCES users(id) + ); + `); +} + +export function openDatabase(dbPath: string): Database { + mkdirSync(dirname(dbPath), { recursive: true }); + const db = new Database(dbPath, { create: true }); + createSchema(db); + return db; +} + +function insertSeedData(db: Database) { + const insertOrganization = db.query( + "INSERT INTO organizations (id, slug, name, plan, industry, created_at) VALUES (?, ?, ?, ?, ?, ?)", + ); + const insertUser = db.query( + "INSERT INTO users (id, org_id, full_name, email, role, timezone) VALUES (?, ?, ?, ?, ?, ?)", + ); + const insertProject = db.query( + "INSERT INTO projects (id, org_id, key, name, status, owner_user_id, due_date) VALUES (?, ?, ?, ?, ?, ?, ?)", + ); + const insertTask = db.query( + "INSERT INTO tasks (id, org_id, project_id, assignee_user_id, title, description, status, priority, story_points, due_date, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + ); + const insertLabel = db.query( + "INSERT INTO labels (id, org_id, name, color) VALUES (?, ?, ?, ?)", + ); + const insertTaskLabel = db.query( + "INSERT INTO task_labels (task_id, label_id) VALUES (?, ?)", + ); + const insertComment = db.query( + "INSERT INTO comments (id, task_id, author_user_id, body, created_at) VALUES (?, ?, ?, ?, ?)", + ); + + const seed = db.transaction(() => { + for (const org of organizations) { + insertOrganization.run( + org.id, + org.slug, + org.name, + org.plan, + org.industry, + org.createdAt, + ); + } + + for (const user of users) { + insertUser.run( + user.id, + user.orgId, + user.fullName, + user.email, + user.role, + user.timezone, + ); + } + + for (const project of projects) { + insertProject.run( + project.id, + project.orgId, + project.key, + project.name, + project.status, + project.ownerUserId, + project.dueDate, + ); + } + + for (const task of tasks) { + insertTask.run( + task.id, + task.orgId, + task.projectId, + task.assigneeUserId, + task.title, + task.description, + task.status, + task.priority, + task.storyPoints, + task.dueDate, + task.createdAt, + task.updatedAt, + ); + } + + for (const label of labels) { + insertLabel.run(label.id, label.orgId, label.name, label.color); + } + + for (const assignment of taskLabels) { + insertTaskLabel.run(assignment.taskId, assignment.labelId); + } + + for (const comment of comments) { + insertComment.run( + comment.id, + comment.taskId, + comment.authorUserId, + comment.body, + comment.createdAt, + ); + } + }); + + seed(); +} + +export function ensureSeededDatabase(dbPath: string) { + const db = openDatabase(dbPath); + const count = db.query("SELECT COUNT(*) AS count FROM organizations").get() as { count: number }; + if (count.count === 0) { + insertSeedData(db); + } + + return db; +} + +export function reseedDatabase(dbPath: string) { + mkdirSync(dirname(dbPath), { recursive: true }); + if (existsSync(dbPath)) { + rmSync(dbPath); + } + + const db = openDatabase(dbPath); + insertSeedData(db); + return db; +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..5850ca1 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,223 @@ +import type { Database } from "bun:sqlite"; +import { getConfig } from "./config"; +import { ensureSeededDatabase } from "./database"; +import { openApiDocument } from "./openapi"; +import { + createTask, + getOrganizationSummary, + getTaskDetail, + listOrganizations, + listProjects, + listTasks, + listUsers, + updateTask, +} from "./repository"; + +function json(body: unknown, init: ResponseInit = {}) { + const headers = new Headers(init.headers); + headers.set("content-type", "application/json; charset=utf-8"); + headers.set("access-control-allow-origin", "*"); + headers.set("access-control-allow-methods", "GET,POST,PATCH,OPTIONS"); + headers.set("access-control-allow-headers", "content-type"); + return new Response(JSON.stringify(body, null, 2), { ...init, headers }); +} + +function notFound(message: string) { + return json({ error: message }, { status: 404 }); +} + +function badRequest(message: string) { + return json({ error: message }, { status: 400 }); +} + +async function readJson(request: Request) { + try { + return await request.json(); + } catch { + return null; + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function validateCreateTask(body: unknown) { + if (!isRecord(body)) { + return { error: "Request body must be a JSON object." }; + } + + if (typeof body.orgId !== "string" || body.orgId.trim() === "") { + return { error: "orgId is required." }; + } + if (typeof body.projectId !== "string" || body.projectId.trim() === "") { + return { error: "projectId is required." }; + } + if (typeof body.title !== "string" || body.title.trim() === "") { + return { error: "title is required." }; + } + + return { + value: { + orgId: body.orgId.trim(), + projectId: body.projectId.trim(), + assigneeUserId: + typeof body.assigneeUserId === "string" ? body.assigneeUserId.trim() : null, + title: body.title.trim(), + description: + typeof body.description === "string" ? body.description.trim() : undefined, + status: typeof body.status === "string" ? body.status.trim() : undefined, + priority: typeof body.priority === "string" ? body.priority.trim() : undefined, + storyPoints: + typeof body.storyPoints === "number" ? Math.trunc(body.storyPoints) : undefined, + dueDate: typeof body.dueDate === "string" ? body.dueDate : null, + }, + }; +} + +function validateUpdateTask(body: unknown) { + if (!isRecord(body)) { + return { error: "Request body must be a JSON object." }; + } + + return { + value: { + title: typeof body.title === "string" ? body.title.trim() : undefined, + description: + typeof body.description === "string" ? body.description.trim() : undefined, + status: typeof body.status === "string" ? body.status.trim() : undefined, + priority: typeof body.priority === "string" ? body.priority.trim() : undefined, + assigneeUserId: + typeof body.assigneeUserId === "string" + ? body.assigneeUserId.trim() + : body.assigneeUserId === null + ? null + : undefined, + storyPoints: + typeof body.storyPoints === "number" + ? Math.trunc(body.storyPoints) + : body.storyPoints === null + ? null + : undefined, + dueDate: + typeof body.dueDate === "string" + ? body.dueDate + : body.dueDate === null + ? null + : undefined, + }, + }; +} + +export function createServer(db: Database) { + return { + port: getConfig().port, + fetch: async (request: Request) => { + if (request.method === "OPTIONS") { + return json({ ok: true }); + } + + const url = new URL(request.url); + const parts = url.pathname.split("/").filter(Boolean); + + if (request.method === "GET" && url.pathname === "/health") { + return json({ status: "ok" }); + } + + if ( + request.method === "GET" && + (url.pathname === "/openapi.json" || + url.pathname === "/swagger.json" || + url.pathname === "/api-docs") + ) { + return json(openApiDocument); + } + + if (request.method === "GET" && url.pathname === "/orgs") { + return json({ organizations: listOrganizations(db) }); + } + + if (request.method === "GET" && url.pathname === "/users") { + return json({ users: listUsers(db, url.searchParams.get("orgId")) }); + } + + if (request.method === "GET" && url.pathname === "/projects") { + return json({ projects: listProjects(db, url.searchParams.get("orgId")) }); + } + + if (request.method === "GET" && url.pathname === "/tasks") { + return json({ + tasks: listTasks(db, { + orgId: url.searchParams.get("orgId"), + projectId: url.searchParams.get("projectId"), + status: url.searchParams.get("status"), + priority: url.searchParams.get("priority"), + assigneeUserId: url.searchParams.get("assigneeUserId"), + }), + }); + } + + if (request.method === "POST" && url.pathname === "/tasks") { + const payload = validateCreateTask(await readJson(request)); + if ("error" in payload) { + return badRequest(payload.error); + } + + const task = createTask(db, payload.value); + return json({ task }, { status: 201 }); + } + + if (parts[0] === "orgs" && parts.length === 2 && request.method === "GET") { + const summary = getOrganizationSummary(db, parts[1]!); + return summary ? json({ organization: summary }) : notFound("Organization not found."); + } + + if (parts[0] === "orgs" && parts[2] === "projects" && request.method === "GET") { + return json({ projects: listProjects(db, parts[1]!) }); + } + + if (parts[0] === "orgs" && parts[2] === "tasks" && request.method === "GET") { + return json({ + tasks: listTasks(db, { + orgId: parts[1]!, + projectId: url.searchParams.get("projectId"), + status: url.searchParams.get("status"), + priority: url.searchParams.get("priority"), + assigneeUserId: url.searchParams.get("assigneeUserId"), + }), + }); + } + + if (parts[0] === "tasks" && parts.length === 2 && request.method === "GET") { + const task = getTaskDetail(db, parts[1]!); + return task ? json({ task }) : notFound("Task not found."); + } + + if (parts[0] === "tasks" && parts.length === 2 && request.method === "PATCH") { + const payload = validateUpdateTask(await readJson(request)); + if ("error" in payload) { + return badRequest(payload.error); + } + + const task = updateTask(db, parts[1]!, payload.value); + return task ? json({ task }) : notFound("Task not found."); + } + + return notFound("Route not found."); + }, + }; +} + +function main() { + const config = getConfig(); + const db = ensureSeededDatabase(config.dbPath); + Bun.serve(createServer(db)); + console.info("mock-task-api:listening", { + port: config.port, + dbPath: config.dbPath, + }); +} + +if (import.meta.main) { + main(); +} diff --git a/src/openapi.ts b/src/openapi.ts new file mode 100644 index 0000000..0bb8fd9 --- /dev/null +++ b/src/openapi.ts @@ -0,0 +1,571 @@ +const taskProperties = { + id: { type: "string" }, + orgId: { type: "string" }, + projectId: { type: "string" }, + assigneeUserId: { type: ["string", "null"] }, + title: { type: "string" }, + description: { type: "string" }, + status: { type: "string", enum: ["todo", "in_progress", "in_review", "blocked", "done"] }, + priority: { type: "string", enum: ["low", "medium", "high", "urgent"] }, + storyPoints: { type: ["integer", "null"] }, + dueDate: { type: ["string", "null"], format: "date" }, + createdAt: { type: "string", format: "date-time" }, + updatedAt: { type: "string", format: "date-time" }, + projectName: { type: "string" }, + projectKey: { type: "string" }, + assigneeName: { type: ["string", "null"] }, +} as const; + +export const openApiDocument = { + openapi: "3.1.0", + info: { + title: "POC Mock Task API", + version: "1.0.0", + description: + "Mock multi-tenant task manager API for orgs, projects, users, and task workflows.", + }, + servers: [ + { + url: "http://localhost:3010", + description: "Local development server", + }, + ], + tags: [ + { name: "Health" }, + { name: "Organizations" }, + { name: "Users" }, + { name: "Projects" }, + { name: "Tasks" }, + ], + paths: { + "/health": { + get: { + tags: ["Health"], + summary: "Check service health", + responses: { + "200": { + description: "Service is healthy", + content: { + "application/json": { + schema: { + type: "object", + required: ["status"], + properties: { + status: { type: "string", enum: ["ok"] }, + }, + }, + }, + }, + }, + }, + }, + }, + "/orgs": { + get: { + tags: ["Organizations"], + summary: "List organizations", + responses: { + "200": { + description: "Organizations with summary counts", + content: { + "application/json": { + schema: { + type: "object", + required: ["organizations"], + properties: { + organizations: { + type: "array", + items: { $ref: "#/components/schemas/OrganizationSummaryListItem" }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + "/orgs/{orgId}": { + get: { + tags: ["Organizations"], + summary: "Get organization detail", + parameters: [ + { + name: "orgId", + in: "path", + required: true, + schema: { type: "string" }, + }, + ], + responses: { + "200": { + description: "Organization detail and task status breakdown", + content: { + "application/json": { + schema: { + type: "object", + required: ["organization"], + properties: { + organization: { $ref: "#/components/schemas/OrganizationDetail" }, + }, + }, + }, + }, + }, + "404": { + description: "Organization not found", + }, + }, + }, + }, + "/orgs/{orgId}/projects": { + get: { + tags: ["Projects"], + summary: "List projects for an organization", + parameters: [ + { + name: "orgId", + in: "path", + required: true, + schema: { type: "string" }, + }, + ], + responses: { + "200": { + description: "Projects for the org", + content: { + "application/json": { + schema: { + type: "object", + required: ["projects"], + properties: { + projects: { + type: "array", + items: { $ref: "#/components/schemas/Project" }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + "/orgs/{orgId}/tasks": { + get: { + tags: ["Tasks"], + summary: "List tasks for an organization", + parameters: [ + { + name: "orgId", + in: "path", + required: true, + schema: { type: "string" }, + }, + { + name: "projectId", + in: "query", + schema: { type: "string" }, + }, + { + name: "status", + in: "query", + schema: { type: "string" }, + }, + { + name: "priority", + in: "query", + schema: { type: "string" }, + }, + { + name: "assigneeUserId", + in: "query", + schema: { type: "string" }, + }, + ], + responses: { + "200": { + description: "Filtered org task list", + content: { + "application/json": { + schema: { + type: "object", + required: ["tasks"], + properties: { + tasks: { + type: "array", + items: { $ref: "#/components/schemas/TaskListItem" }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + "/users": { + get: { + tags: ["Users"], + summary: "List users", + parameters: [ + { + name: "orgId", + in: "query", + schema: { type: "string" }, + }, + ], + responses: { + "200": { + description: "User list", + content: { + "application/json": { + schema: { + type: "object", + required: ["users"], + properties: { + users: { + type: "array", + items: { $ref: "#/components/schemas/User" }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + "/projects": { + get: { + tags: ["Projects"], + summary: "List projects", + parameters: [ + { + name: "orgId", + in: "query", + schema: { type: "string" }, + }, + ], + responses: { + "200": { + description: "Project list", + content: { + "application/json": { + schema: { + type: "object", + required: ["projects"], + properties: { + projects: { + type: "array", + items: { $ref: "#/components/schemas/Project" }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + "/tasks": { + get: { + tags: ["Tasks"], + summary: "List tasks", + parameters: [ + { name: "orgId", in: "query", schema: { type: "string" } }, + { name: "projectId", in: "query", schema: { type: "string" } }, + { name: "status", in: "query", schema: { type: "string" } }, + { name: "priority", in: "query", schema: { type: "string" } }, + { name: "assigneeUserId", in: "query", schema: { type: "string" } }, + ], + responses: { + "200": { + description: "Filtered task list", + content: { + "application/json": { + schema: { + type: "object", + required: ["tasks"], + properties: { + tasks: { + type: "array", + items: { $ref: "#/components/schemas/TaskListItem" }, + }, + }, + }, + }, + }, + }, + }, + }, + post: { + tags: ["Tasks"], + summary: "Create a task", + requestBody: { + required: true, + content: { + "application/json": { + schema: { $ref: "#/components/schemas/CreateTaskRequest" }, + }, + }, + }, + responses: { + "201": { + description: "Created task detail", + content: { + "application/json": { + schema: { + type: "object", + required: ["task"], + properties: { + task: { $ref: "#/components/schemas/TaskDetail" }, + }, + }, + }, + }, + }, + "400": { + description: "Invalid payload", + }, + }, + }, + }, + "/tasks/{taskId}": { + get: { + tags: ["Tasks"], + summary: "Get task detail", + parameters: [ + { + name: "taskId", + in: "path", + required: true, + schema: { type: "string" }, + }, + ], + responses: { + "200": { + description: "Task detail with labels and comments", + content: { + "application/json": { + schema: { + type: "object", + required: ["task"], + properties: { + task: { $ref: "#/components/schemas/TaskDetail" }, + }, + }, + }, + }, + }, + "404": { + description: "Task not found", + }, + }, + }, + patch: { + tags: ["Tasks"], + summary: "Update a task", + parameters: [ + { + name: "taskId", + in: "path", + required: true, + schema: { type: "string" }, + }, + ], + requestBody: { + required: true, + content: { + "application/json": { + schema: { $ref: "#/components/schemas/UpdateTaskRequest" }, + }, + }, + }, + responses: { + "200": { + description: "Updated task detail", + content: { + "application/json": { + schema: { + type: "object", + required: ["task"], + properties: { + task: { $ref: "#/components/schemas/TaskDetail" }, + }, + }, + }, + }, + }, + "404": { + description: "Task not found", + }, + }, + }, + }, + "/openapi.json": { + get: { + tags: ["Health"], + summary: "Get OpenAPI document", + responses: { + "200": { + description: "OpenAPI specification", + }, + }, + }, + }, + "/swagger.json": { + get: { + tags: ["Health"], + summary: "Get OpenAPI document via swagger alias", + responses: { + "200": { + description: "OpenAPI specification", + }, + }, + }, + }, + "/api-docs": { + get: { + tags: ["Health"], + summary: "Get OpenAPI document via api-docs alias", + responses: { + "200": { + description: "OpenAPI specification", + }, + }, + }, + }, + }, + components: { + schemas: { + OrganizationSummaryListItem: { + type: "object", + properties: { + id: { type: "string" }, + slug: { type: "string" }, + name: { type: "string" }, + plan: { type: "string" }, + industry: { type: "string" }, + createdAt: { type: "string", format: "date-time" }, + projectCount: { type: "integer" }, + taskCount: { type: "integer" }, + openTaskCount: { type: "integer" }, + }, + }, + OrganizationDetail: { + allOf: [ + { $ref: "#/components/schemas/OrganizationSummaryListItem" }, + { + type: "object", + properties: { + userCount: { type: "integer" }, + statusBreakdown: { + type: "array", + items: { + type: "object", + properties: { + status: { type: "string" }, + count: { type: "integer" }, + }, + }, + }, + }, + }, + ], + }, + User: { + type: "object", + properties: { + id: { type: "string" }, + orgId: { type: "string" }, + fullName: { type: "string" }, + email: { type: "string", format: "email" }, + role: { type: "string" }, + timezone: { type: "string" }, + }, + }, + Project: { + type: "object", + properties: { + id: { type: "string" }, + orgId: { type: "string" }, + key: { type: "string" }, + name: { type: "string" }, + status: { type: "string" }, + ownerUserId: { type: "string" }, + ownerName: { type: ["string", "null"] }, + dueDate: { type: ["string", "null"], format: "date" }, + taskCount: { type: "integer" }, + }, + }, + TaskListItem: { + type: "object", + properties: taskProperties, + }, + Label: { + type: "object", + properties: { + id: { type: "string" }, + name: { type: "string" }, + color: { type: "string" }, + }, + }, + Comment: { + type: "object", + properties: { + id: { type: "string" }, + authorUserId: { type: "string" }, + authorName: { type: "string" }, + body: { type: "string" }, + createdAt: { type: "string", format: "date-time" }, + }, + }, + TaskDetail: { + allOf: [ + { $ref: "#/components/schemas/TaskListItem" }, + { + type: "object", + properties: { + labels: { + type: "array", + items: { $ref: "#/components/schemas/Label" }, + }, + comments: { + type: "array", + items: { $ref: "#/components/schemas/Comment" }, + }, + }, + }, + ], + }, + CreateTaskRequest: { + type: "object", + required: ["orgId", "projectId", "title"], + properties: { + orgId: { type: "string" }, + projectId: { type: "string" }, + assigneeUserId: { type: ["string", "null"] }, + title: { type: "string" }, + description: { type: "string" }, + status: { type: "string" }, + priority: { type: "string" }, + storyPoints: { type: ["integer", "null"] }, + dueDate: { type: ["string", "null"], format: "date" }, + }, + }, + UpdateTaskRequest: { + type: "object", + properties: { + title: { type: "string" }, + description: { type: "string" }, + status: { type: "string" }, + priority: { type: "string" }, + assigneeUserId: { type: ["string", "null"] }, + storyPoints: { type: ["integer", "null"] }, + dueDate: { type: ["string", "null"], format: "date" }, + }, + }, + }, + }, +} as const; diff --git a/src/repository.ts b/src/repository.ts new file mode 100644 index 0000000..56fd0e1 --- /dev/null +++ b/src/repository.ts @@ -0,0 +1,346 @@ +import type { Database } from "bun:sqlite"; + +interface TaskFilters { + orgId?: string | null; + projectId?: string | null; + status?: string | null; + priority?: string | null; + assigneeUserId?: string | null; +} + +function toCamelCaseTask(row: Record) { + return { + id: row.id, + orgId: row.org_id, + projectId: row.project_id, + assigneeUserId: row.assignee_user_id, + title: row.title, + description: row.description, + status: row.status, + priority: row.priority, + storyPoints: row.story_points, + dueDate: row.due_date, + createdAt: row.created_at, + updatedAt: row.updated_at, + projectName: row.project_name, + projectKey: row.project_key, + assigneeName: row.assignee_name, + }; +} + +export function listOrganizations(db: Database) { + return db + .query(` + SELECT + o.id, + o.slug, + o.name, + o.plan, + o.industry, + o.created_at AS createdAt, + COUNT(DISTINCT p.id) AS projectCount, + COUNT(DISTINCT t.id) AS taskCount, + COUNT(DISTINCT CASE WHEN t.status != 'done' THEN t.id END) AS openTaskCount + FROM organizations o + LEFT JOIN projects p ON p.org_id = o.id + LEFT JOIN tasks t ON t.org_id = o.id + GROUP BY o.id + ORDER BY o.name + `) + .all(); +} + +export function getOrganizationSummary(db: Database, orgId: string) { + const org = db + .query(` + SELECT + o.id, + o.slug, + o.name, + o.plan, + o.industry, + o.created_at AS createdAt, + COUNT(DISTINCT u.id) AS userCount, + COUNT(DISTINCT p.id) AS projectCount, + COUNT(DISTINCT t.id) AS taskCount + FROM organizations o + LEFT JOIN users u ON u.org_id = o.id + LEFT JOIN projects p ON p.org_id = o.id + LEFT JOIN tasks t ON t.org_id = o.id + WHERE o.id = ? + GROUP BY o.id + `) + .get(orgId); + + if (!org) { + return null; + } + + const statusBreakdown = db + .query(` + SELECT status, COUNT(*) AS count + FROM tasks + WHERE org_id = ? + GROUP BY status + ORDER BY count DESC, status ASC + `) + .all(orgId); + + return { + ...org, + statusBreakdown, + }; +} + +export function listUsers(db: Database, orgId?: string | null) { + if (orgId) { + return db + .query(` + SELECT + id, + org_id AS orgId, + full_name AS fullName, + email, + role, + timezone + FROM users + WHERE org_id = ? + ORDER BY full_name + `) + .all(orgId); + } + + return db + .query(` + SELECT + id, + org_id AS orgId, + full_name AS fullName, + email, + role, + timezone + FROM users + ORDER BY org_id, full_name + `) + .all(); +} + +export function listProjects(db: Database, orgId?: string | null) { + const sql = ` + SELECT + p.id, + p.org_id AS orgId, + p.key, + p.name, + p.status, + p.owner_user_id AS ownerUserId, + p.due_date AS dueDate, + u.full_name AS ownerName, + COUNT(t.id) AS taskCount + FROM projects p + LEFT JOIN users u ON u.id = p.owner_user_id + LEFT JOIN tasks t ON t.project_id = p.id + ${orgId ? "WHERE p.org_id = ?" : ""} + GROUP BY p.id + ORDER BY p.name + `; + + return orgId ? db.query(sql).all(orgId) : db.query(sql).all(); +} + +export function listTasks(db: Database, filters: TaskFilters = {}) { + const conditions: string[] = []; + const values: string[] = []; + + if (filters.orgId) { + conditions.push("t.org_id = ?"); + values.push(filters.orgId); + } + if (filters.projectId) { + conditions.push("t.project_id = ?"); + values.push(filters.projectId); + } + if (filters.status) { + conditions.push("t.status = ?"); + values.push(filters.status); + } + if (filters.priority) { + conditions.push("t.priority = ?"); + values.push(filters.priority); + } + if (filters.assigneeUserId) { + conditions.push("t.assignee_user_id = ?"); + values.push(filters.assigneeUserId); + } + + const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : ""; + const rows = db + .query(` + SELECT + t.*, + p.name AS project_name, + p.key AS project_key, + u.full_name AS assignee_name + FROM tasks t + JOIN projects p ON p.id = t.project_id + LEFT JOIN users u ON u.id = t.assignee_user_id + ${whereClause} + ORDER BY + CASE t.priority + WHEN 'urgent' THEN 1 + WHEN 'high' THEN 2 + WHEN 'medium' THEN 3 + ELSE 4 + END, + t.due_date ASC, + t.updated_at DESC + `) + .all(...values) as Record[]; + + return rows.map(toCamelCaseTask); +} + +export function getTaskDetail(db: Database, taskId: string) { + const row = db + .query(` + SELECT + t.*, + p.name AS project_name, + p.key AS project_key, + u.full_name AS assignee_name + FROM tasks t + JOIN projects p ON p.id = t.project_id + LEFT JOIN users u ON u.id = t.assignee_user_id + WHERE t.id = ? + `) + .get(taskId) as Record | null; + + if (!row) { + return null; + } + + const labels = db + .query(` + SELECT l.id, l.name, l.color + FROM task_labels tl + JOIN labels l ON l.id = tl.label_id + WHERE tl.task_id = ? + ORDER BY l.name + `) + .all(taskId); + + const comments = db + .query(` + SELECT + c.id, + c.body, + c.created_at AS createdAt, + c.author_user_id AS authorUserId, + u.full_name AS authorName + FROM comments c + JOIN users u ON u.id = c.author_user_id + WHERE c.task_id = ? + ORDER BY c.created_at ASC + `) + .all(taskId); + + return { + ...toCamelCaseTask(row), + labels, + comments, + }; +} + +export interface CreateTaskInput { + orgId: string; + projectId: string; + assigneeUserId?: string | null; + title: string; + description?: string; + status?: string; + priority?: string; + storyPoints?: number | null; + dueDate?: string | null; +} + +export function createTask(db: Database, input: CreateTaskInput) { + const now = new Date().toISOString(); + const id = `task_${Math.random().toString(36).slice(2, 10)}`; + + db.query(` + INSERT INTO tasks ( + id, org_id, project_id, assignee_user_id, title, description, status, priority, story_points, due_date, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run( + id, + input.orgId, + input.projectId, + input.assigneeUserId ?? null, + input.title, + input.description ?? "", + input.status ?? "todo", + input.priority ?? "medium", + input.storyPoints ?? null, + input.dueDate ?? null, + now, + now, + ); + + return getTaskDetail(db, id); +} + +export interface UpdateTaskInput { + title?: string; + description?: string; + status?: string; + priority?: string; + assigneeUserId?: string | null; + storyPoints?: number | null; + dueDate?: string | null; +} + +export function updateTask(db: Database, taskId: string, input: UpdateTaskInput) { + const existing = db.query("SELECT id FROM tasks WHERE id = ?").get(taskId); + if (!existing) { + return null; + } + + const fields: string[] = []; + const values: Array = []; + + if (input.title !== undefined) { + fields.push("title = ?"); + values.push(input.title); + } + if (input.description !== undefined) { + fields.push("description = ?"); + values.push(input.description); + } + if (input.status !== undefined) { + fields.push("status = ?"); + values.push(input.status); + } + if (input.priority !== undefined) { + fields.push("priority = ?"); + values.push(input.priority); + } + if (input.assigneeUserId !== undefined) { + fields.push("assignee_user_id = ?"); + values.push(input.assigneeUserId); + } + if (input.storyPoints !== undefined) { + fields.push("story_points = ?"); + values.push(input.storyPoints); + } + if (input.dueDate !== undefined) { + fields.push("due_date = ?"); + values.push(input.dueDate); + } + + fields.push("updated_at = ?"); + values.push(new Date().toISOString()); + values.push(taskId); + + db.query(`UPDATE tasks SET ${fields.join(", ")} WHERE id = ?`).run(...values); + return getTaskDetail(db, taskId); +} diff --git a/tests/api.test.ts b/tests/api.test.ts new file mode 100644 index 0000000..38656f1 --- /dev/null +++ b/tests/api.test.ts @@ -0,0 +1,124 @@ +import { afterEach, describe, expect, it } from "bun:test"; +import { mkdtempSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { reseedDatabase } from "../src/database"; +import { createServer } from "../src/index"; + +let tempDir: string | null = null; + +function createTestServer() { + tempDir = mkdtempSync(join(tmpdir(), "mock-task-api-http-")); + const db = reseedDatabase(join(tempDir, "test.sqlite")); + return createServer(db); +} + +async function readJson(response: Response) { + return (await response.json()) as Record; +} + +afterEach(() => { + if (tempDir) { + rmSync(tempDir, { recursive: true, force: true }); + tempDir = null; + } +}); + +describe("mock task API", () => { + it("serves a health endpoint", async () => { + const server = createTestServer(); + + const response = await server.fetch(new Request("http://mock.local/health")); + const body = await readJson(response); + + expect(response.status).toBe(200); + expect(body.status).toBe("ok"); + }); + + it("lists organizations", async () => { + const server = createTestServer(); + + const response = await server.fetch(new Request("http://mock.local/orgs")); + const body = await readJson(response); + + expect(response.status).toBe(200); + expect(Array.isArray(body.organizations)).toBe(true); + expect((body.organizations as unknown[]).length).toBe(3); + }); + + it("filters tasks by org and status", async () => { + const server = createTestServer(); + + const response = await server.fetch( + new Request("http://mock.local/tasks?orgId=org_acme&status=blocked"), + ); + const body = await readJson(response); + const tasks = body.tasks as Array>; + + expect(response.status).toBe(200); + expect(tasks.length).toBe(1); + expect(tasks[0]?.id).toBe("task_1003"); + }); + + it("creates a task through the HTTP contract", async () => { + const server = createTestServer(); + + const response = await server.fetch( + new Request("http://mock.local/tasks", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + orgId: "org_acme", + projectId: "proj_acme_ops", + title: "Run API smoke test", + priority: "low", + }), + }), + ); + const body = await readJson(response); + + expect(response.status).toBe(201); + expect((body.task as Record).title).toBe("Run API smoke test"); + }); + + it("updates a task through the HTTP contract", async () => { + const server = createTestServer(); + + const response = await server.fetch( + new Request("http://mock.local/tasks/task_1001", { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + status: "done", + assigneeUserId: null, + }), + }), + ); + const body = await readJson(response); + + expect(response.status).toBe(200); + expect((body.task as Record).status).toBe("done"); + expect((body.task as Record).assigneeUserId).toBeNull(); + }); + + it("returns a valid openapi document at the import path", async () => { + const server = createTestServer(); + + const response = await server.fetch(new Request("http://mock.local/openapi.json")); + const body = await readJson(response); + const paths = body.paths as Record; + + expect(response.status).toBe(200); + expect(body.openapi).toBe("3.1.0"); + expect(paths["/tasks"]).toBeDefined(); + expect(paths["/orgs/{orgId}/tasks"]).toBeDefined(); + }); + + it("supports the swagger alias path used by import tools", async () => { + const server = createTestServer(); + + const response = await server.fetch(new Request("http://mock.local/swagger.json")); + + expect(response.status).toBe(200); + }); +}); diff --git a/tests/repository.test.ts b/tests/repository.test.ts new file mode 100644 index 0000000..3045da7 --- /dev/null +++ b/tests/repository.test.ts @@ -0,0 +1,73 @@ +import { afterEach, describe, expect, it } from "bun:test"; +import { mkdtempSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { reseedDatabase } from "../src/database"; +import { createTask, getOrganizationSummary, getTaskDetail, listOrganizations, listTasks, updateTask } from "../src/repository"; + +let tempDir: string | null = null; + +function createDb() { + tempDir = mkdtempSync(join(tmpdir(), "mock-task-api-")); + return reseedDatabase(join(tempDir, "test.sqlite")); +} + +afterEach(() => { + if (tempDir) { + rmSync(tempDir, { recursive: true, force: true }); + tempDir = null; + } +}); + +describe("mock task repository", () => { + it("lists seeded organizations and tasks", () => { + const db = createDb(); + + const organizations = listOrganizations(db); + const tasks = listTasks(db, { orgId: "org_acme" }); + + expect(organizations.length).toBe(3); + expect(tasks.length).toBeGreaterThan(0); + expect(organizations[0]).toHaveProperty("openTaskCount"); + }); + + it("returns task detail with labels and comments", () => { + const db = createDb(); + + const task = getTaskDetail(db, "task_1001"); + + expect(task?.labels.length).toBeGreaterThan(0); + expect(task?.comments.length).toBeGreaterThan(0); + }); + + it("creates and updates a task", () => { + const db = createDb(); + + const created = createTask(db, { + orgId: "org_acme", + projectId: "proj_acme_ops", + assigneeUserId: "user_acme_2", + title: "Mock task from test", + priority: "low", + }); + + expect(created?.title).toBe("Mock task from test"); + + const updated = updateTask(db, created!.id as string, { + status: "done", + assigneeUserId: null, + }); + + expect(updated?.status).toBe("done"); + expect(updated?.assigneeUserId).toBeNull(); + }); + + it("returns org summary status counts", () => { + const db = createDb(); + + const summary = getOrganizationSummary(db, "org_acme"); + + expect(summary).not.toBeNull(); + expect(summary?.statusBreakdown.length).toBeGreaterThan(0); + }); +}); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..2fd397d --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "allowJs": false, + "noEmit": true, + "types": ["bun"], + "skipLibCheck": true + }, + "include": ["src/**/*.ts", "scripts/**/*.ts", "tests/**/*.ts"] +}