added repo
This commit is contained in:
3
.dockerignore
Normal file
3
.dockerignore
Normal file
@@ -0,0 +1,3 @@
|
||||
data
|
||||
node_modules
|
||||
coverage
|
||||
16
Dockerfile
Normal file
16
Dockerfile
Normal 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
170
README.md
Normal 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
12
compose.yml
Normal 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
|
||||
BIN
data/mock-task-manager.sqlite
Normal file
BIN
data/mock-task-manager.sqlite
Normal file
Binary file not shown.
12
package.json
Normal file
12
package.json
Normal 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
10
scripts/seed.ts
Normal 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
19
src/config.ts
Normal 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
526
src/database.ts
Normal 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
223
src/index.ts
Normal 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
571
src/openapi.ts
Normal 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
346
src/repository.ts
Normal 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
124
tests/api.test.ts
Normal 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
73
tests/repository.test.ts
Normal 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
13
tsconfig.json
Normal 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"]
|
||||
}
|
||||
Reference in New Issue
Block a user