diff --git a/README.md b/README.md index 494f1c75..7e4d5e2a 100644 --- a/README.md +++ b/README.md @@ -1,43 +1,84 @@ -# Candidate Assessment: Spec-Driven Development With Codegen Tools - -This assessment evaluates how you use modern code generation tools (for example `5.2-Codex`, `Claude`, `Copilot`, and similar) to design, build, and test a software application using a spec-driven development pattern. You may build a frontend, a backend, or both. - -## Goals -- Build a working application with at least one meaningful feature. -- Create a testing framework to validate the application. -- Demonstrate effective use of code generation tools to accelerate delivery. -- Show clear, maintainable engineering practices. - -## Deliverables -- Application source code in this repository. -- A test suite and test harness that can be run locally. -- Documentation that explains how to run the app and the tests. - -## Scope Options -Pick one: -- Frontend-only application. -- Backend-only application. -- Full-stack application. - -Your solution should include at least one real workflow, for example: -- Create and view a resource. -- Search or filter data. -- Persist data in memory or storage. - -## Rules -- You must use a code generation tool (for example `5.2-Codex`, `Claude`, or similar). You can use multiple tools. -- You must build the application and a testing framework for it. -- The application and tests must run locally. -- Do not include secrets or credentials in this repository. - -## Evaluation Criteria -- Working product: Does the app do what it claims? -- Test coverage: Do tests cover key workflows and edge cases? -- Engineering quality: Clarity, structure, and maintainability. -- Use of codegen: How effectively you used tools to accelerate work. -- Documentation: Clear setup and run instructions. - -## What to Submit -- When you are complete, put up a Pull Request against this repository with your changes. -- A short summary of your approach and tools used in your PR submission -- Any additional information or approach that helped you. +# Spec-Driven Task App + +This repository contains a small spec-driven full-stack example: a FastAPI backend and a plain HTML + JavaScript frontend for managing tasks in memory. + +Project structure +``` +backend/ + main.py + models.py + routes.py + storage.py +frontend/ + index.html +tests/ + test_tasks.py +requirements.txt +README.md +``` + +Quickstart + +1. Install dependencies (recommended in a virtualenv): + +```bash +pip install -r requirements.txt +``` + +2. Run the backend: + +```bash +python -m backend.main +``` + +The API will be available at `http://127.0.0.1:8000` and the OpenAPI docs at `http://127.0.0.1:8000/docs`. + +# Spec-Driven Task Manager + +This repository contains a small full-stack Task Manager implemented using a spec-driven approach. + +Task spec (single source of truth): + +{ + "id": "uuid", + "title": "string", + "description": "string", + "status": "pending | completed" +} + +## Run the backend + +Install dependencies into a virtualenv, then run with Uvicorn: + +```bash +python -m pip install -r requirements.txt +uvicorn backend.main:app --reload --port 8000 +``` + +The backend serves the API and the frontend. Open http://localhost:8000/ to use the web UI. + +API endpoints: +- `POST /tasks` — create a task (JSON: title, description) +- `GET /tasks` — list tasks +- `GET /tasks?status=pending|completed` — filter tasks by status +- `PATCH /tasks/{id}/status` — update a task's status (JSON: {status: "completed"}) + +Test-only endpoint: +- `POST /test/clear` — clears in-memory storage (used by the test suite) + +## Frontend + +Open http://localhost:8000/ in your browser. The single `index.html` page allows creating tasks, filtering, and toggling status. + +## Run tests + +```bash +python -m pip install -r requirements.txt +pytest -q +``` + +## How AI / Code generation tools were used + +- The project was implemented following a spec-driven workflow. Pydantic models were derived from the task spec and used for validation and OpenAPI generation. +- Code generation tools were used to scaffold boilerplate (models, routes, storage) and to iterate on API shapes and tests, accelerating implementation while preserving human review and refinement. + diff --git a/backend/__init__.py b/backend/__init__.py new file mode 100644 index 00000000..2f5d08cb --- /dev/null +++ b/backend/__init__.py @@ -0,0 +1 @@ +# backend package diff --git a/backend/__pycache__/__init__.cpython-312.pyc b/backend/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 00000000..da0277e9 Binary files /dev/null and b/backend/__pycache__/__init__.cpython-312.pyc differ diff --git a/backend/__pycache__/main.cpython-312.pyc b/backend/__pycache__/main.cpython-312.pyc new file mode 100644 index 00000000..0f04eff9 Binary files /dev/null and b/backend/__pycache__/main.cpython-312.pyc differ diff --git a/backend/__pycache__/models.cpython-312.pyc b/backend/__pycache__/models.cpython-312.pyc new file mode 100644 index 00000000..7789827f Binary files /dev/null and b/backend/__pycache__/models.cpython-312.pyc differ diff --git a/backend/__pycache__/routes.cpython-312.pyc b/backend/__pycache__/routes.cpython-312.pyc new file mode 100644 index 00000000..c1ec7e05 Binary files /dev/null and b/backend/__pycache__/routes.cpython-312.pyc differ diff --git a/backend/__pycache__/storage.cpython-312.pyc b/backend/__pycache__/storage.cpython-312.pyc new file mode 100644 index 00000000..bbdae459 Binary files /dev/null and b/backend/__pycache__/storage.cpython-312.pyc differ diff --git a/backend/main.py b/backend/main.py new file mode 100644 index 00000000..d6dc3b6d --- /dev/null +++ b/backend/main.py @@ -0,0 +1,29 @@ +from fastapi import FastAPI +from fastapi.staticfiles import StaticFiles +from .routes import router + +app = FastAPI(title="Spec-Driven Task Manager", version="0.1") +app.include_router(router) + +# Serve the frontend folder at the app root (index.html will be served) +app.mount("/", StaticFiles(directory="frontend", html=True), name="frontend") +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +from .routes import router + +app = FastAPI(title="Task API") +app.include_router(router) + +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + + +if __name__ == "__main__": + import uvicorn + + uvicorn.run("backend.main:app", host="0.0.0.0", port=8000, reload=True) diff --git a/backend/models.py b/backend/models.py new file mode 100644 index 00000000..644236c6 --- /dev/null +++ b/backend/models.py @@ -0,0 +1,31 @@ +from pydantic import BaseModel +from typing import Literal + + +class TaskBase(BaseModel): + title: str + description: str + + +class TaskCreate(TaskBase): + pass + + +class Task(TaskBase): + id: str + status: Literal["pending", "completed"] +from pydantic import BaseModel, Field +from typing import Optional, Literal +from uuid import UUID + + +class Task(BaseModel): + id: UUID + title: str = Field(..., min_length=1) + description: Optional[str] = "" + status: Literal["pending", "completed"] = "pending" + + +class TaskCreate(BaseModel): + title: str = Field(..., min_length=1) + description: Optional[str] = "" diff --git a/backend/routes.py b/backend/routes.py new file mode 100644 index 00000000..f3914f02 --- /dev/null +++ b/backend/routes.py @@ -0,0 +1,38 @@ +from fastapi import APIRouter, HTTPException +from typing import List, Optional + +from . import storage +from .models import TaskCreate, Task + +router = APIRouter() + + +@router.post("/tasks", response_model=Task, status_code=201) +def create_task(payload: TaskCreate): + task = storage.create_task(payload.title, payload.description) + return task + + +@router.get("/tasks", response_model=List[Task]) +def get_tasks(status: Optional[str] = None): + if status and status not in ("pending", "completed"): + raise HTTPException(status_code=400, detail="invalid status") + return storage.list_tasks(status) + + +@router.patch("/tasks/{task_id}/status", response_model=Task) +def patch_status(task_id: str, payload: dict): + status = payload.get("status") + if status not in ("pending", "completed"): + raise HTTPException(status_code=400, detail="invalid status") + task = storage.update_status(task_id, status) + if not task: + raise HTTPException(status_code=404, detail="not found") + return task + + +# Test-only convenience endpoint to reset in-memory state +@router.post("/test/clear", status_code=204) +def test_clear(): + storage.clear_storage() + return diff --git a/backend/storage.py b/backend/storage.py new file mode 100644 index 00000000..7f6a6df7 --- /dev/null +++ b/backend/storage.py @@ -0,0 +1,45 @@ +"""In-memory, thread-safe storage for tasks. + +This module stores tasks as dictionaries with string UUID ids. It provides +thread-safe functions matching the API expectations used in the tests and +routes: `create_task`, `list_tasks`, `update_status`, and `clear_storage`. +""" +from typing import Dict, List, Optional +import threading +import uuid + +_lock = threading.Lock() +_tasks: Dict[str, Dict] = {} + + +def clear_storage() -> None: + with _lock: + _tasks.clear() + + +def create_task(title: str, description: str) -> Dict: + with _lock: + task_id = str(uuid.uuid4()) + task = {"id": task_id, "title": title, "description": description, "status": "pending"} + _tasks[task_id] = task + return task.copy() + + +def list_tasks(status: Optional[str] = None) -> List[Dict]: + with _lock: + items = list(_tasks.values()) + if status: + items = [t.copy() for t in items if t["status"] == status] + else: + items = [t.copy() for t in items] + return items + + +def update_status(task_id: str, status: str) -> Optional[Dict]: + if status not in ("pending", "completed"): + raise ValueError("invalid status") + with _lock: + if task_id not in _tasks: + return None + _tasks[task_id]["status"] = status + return _tasks[task_id].copy() diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 00000000..47c36f0d --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,162 @@ + + + + + + Spec-Driven Task Manager + + + +

Tasks

+ +
+ + + +
+ + + +
+ + + + + + + + + + Task App + + + +

Tasks

+ +
+
+ +
+
+ +
+ +
+ +
+ +
+ +
+ + + + diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 00000000..2cd04045 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,6 @@ +fastapi>=0.95.0 +uvicorn>=0.22.0 +pytest>=7.0.0 +fastapi>=0.95.0 +uvicorn[standard]>=0.22.0 +pytest>=7.0.0 diff --git a/tests/__pycache__/test_tasks.cpython-312-pytest-9.0.1.pyc b/tests/__pycache__/test_tasks.cpython-312-pytest-9.0.1.pyc new file mode 100644 index 00000000..471d603f Binary files /dev/null and b/tests/__pycache__/test_tasks.cpython-312-pytest-9.0.1.pyc differ diff --git a/tests/__pycache__/test_tasks.cpython-312-pytest-9.0.2.pyc b/tests/__pycache__/test_tasks.cpython-312-pytest-9.0.2.pyc new file mode 100644 index 00000000..c8776590 Binary files /dev/null and b/tests/__pycache__/test_tasks.cpython-312-pytest-9.0.2.pyc differ diff --git a/tests/test_tasks.py b/tests/test_tasks.py new file mode 100644 index 00000000..29fb9156 --- /dev/null +++ b/tests/test_tasks.py @@ -0,0 +1,63 @@ +import sys +import os +import pytest + +# Ensure repository root is on sys.path so `backend` package is importable during tests +root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +if root not in sys.path: + sys.path.insert(0, root) + +from fastapi.testclient import TestClient +from backend.main import app + + +client = TestClient(app) + + +def setup_function(): + # Clear shared in-memory state via test API + client.post("/test/clear") + + +def test_create_task(): + res = client.post("/tasks", json={"title": "Hello", "description": "World"}) + assert res.status_code == 201 + data = res.json() + assert data["title"] == "Hello" + assert data["description"] == "World" + assert data["status"] == "pending" + + +def test_list_tasks(): + client.post("/tasks", json={"title": "T1", "description": "D1"}) + client.post("/tasks", json={"title": "T2", "description": "D2"}) + res = client.get("/tasks") + assert res.status_code == 200 + data = res.json() + assert isinstance(data, list) + assert len(data) == 2 + + +def test_filter_by_status(): + r1 = client.post("/tasks", json={"title": "A", "description": "a"}).json() + r2 = client.post("/tasks", json={"title": "B", "description": "b"}).json() + # Set one task to completed via API + client.patch(f"/tasks/{r2['id']}/status", json={"status": "completed"}) + res_pending = client.get("/tasks", params={"status": "pending"}) + res_completed = client.get("/tasks", params={"status": "completed"}) + assert all(t["status"] == "pending" for t in res_pending.json()) + assert all(t["status"] == "completed" for t in res_completed.json()) + + +def test_update_status_endpoint(): + r = client.post("/tasks", json={"title": "X", "description": "x"}).json() + res = client.patch(f"/tasks/{r['id']}/status", json={"status": "completed"}) + assert res.status_code == 200 + assert res.json()["status"] == "completed" + + +def test_invalid_input(): + # missing title should return 422 + res = client.post("/tasks", json={"description": "no title"}) + assert res.status_code == 422 +