added repo

This commit is contained in:
2026-03-03 14:25:43 +00:00
commit 4328ada595
15 changed files with 2118 additions and 0 deletions

3
.dockerignore Normal file
View File

@@ -0,0 +1,3 @@
data
node_modules
coverage

16
Dockerfile Normal file
View File

@@ -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"]

170
README.md Normal file
View File

@@ -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.

12
compose.yml Normal file
View File

@@ -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

Binary file not shown.

12
package.json Normal file
View File

@@ -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"
}
}

10
scripts/seed.ts Normal file
View File

@@ -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,
});

19
src/config.ts Normal file
View File

@@ -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,
};
}

526
src/database.ts Normal file
View File

@@ -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;
}

223
src/index.ts Normal file
View File

@@ -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<string, unknown> {
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();
}

571
src/openapi.ts Normal file
View File

@@ -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;

346
src/repository.ts Normal file
View File

@@ -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<string, unknown>) {
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<string, unknown>[];
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<string, unknown> | 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<string | number | null> = [];
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);
}

124
tests/api.test.ts Normal file
View File

@@ -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<string, unknown>;
}
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<Record<string, unknown>>;
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<string, unknown>).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<string, unknown>).status).toBe("done");
expect((body.task as Record<string, unknown>).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<string, unknown>;
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);
});
});

73
tests/repository.test.ts Normal file
View File

@@ -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);
});
});

13
tsconfig.json Normal file
View File

@@ -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"]
}