Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
name: Tests

on:
push:
pull_request:

permissions:
contents: read

jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.9", "3.14"]

steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
cache: pip
- name: Install dependencies
run: python -m pip install -r requirements-dev.txt
- name: Run tests
run: python -m pytest -v
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -318,6 +318,15 @@ Suggested verification prompt:

Note: the completion endpoint writes to `data/tasks.json` after you fix Bug B. That is expected during testing. Just make sure you ask Codex to review that file before the final commit.

### Run the automated test suite

```bash
pip install -r requirements-dev.txt
pytest -v
```

The tests use an isolated copy of `data/tasks.json`, so running them does not modify the sample data.

### Verify the application still runs

```bash
Expand Down
24 changes: 12 additions & 12 deletions app/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,16 @@ def list_tasks(status: str | None = None, q: str | None = None) -> list[dict[str
filtered: list[dict[str, Any]] = []

for task in tasks:
# Instructor note: intentional bug for the lab.
# This uses the literal string "status" instead of the query parameter value.
if status and task["status"] != "status":
if status and task["status"] != status:
continue

# Instructor note: partial feature for the lab.
# The route already accepts `q`, but search is not implemented yet.
if q:
query = q.casefold()
title = task["title"].casefold()
description = task["description"].casefold()
if query not in title and query not in description:
continue

filtered.append(task)

return filtered
Expand Down Expand Up @@ -53,12 +56,9 @@ def complete_task(task_id: int) -> dict[str, Any] | None:

for task in tasks:
if task["id"] == task_id:
updated_task = dict(task)
updated_task["status"] = "done"
updated_task["completed_at"] = datetime.now(timezone.utc).isoformat()

# Instructor note: intentional bug for the lab.
# The updated task is returned, but the stored list is never updated or saved.
return updated_task
task["status"] = "done"
task["completed_at"] = datetime.now(timezone.utc).isoformat()
save_tasks(tasks)
return task

return None
5 changes: 5 additions & 0 deletions requirements-dev.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
-r requirements.txt

httpx>=0.28,<1.0
httpx2>=2.0,<3.0
pytest>=8.0,<10.0
1 change: 1 addition & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
fastapi>=0.115,<1.0
uvicorn>=0.30,<1.0
pydantic>=2.7,<3.0
eval-type-backport>=0.2,<1.0; python_version < "3.10"
15 changes: 15 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
from pathlib import Path

import pytest

from app import store


@pytest.fixture(autouse=True)
def isolated_data_file(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
"""Give every test a fresh copy of the task data."""
source = store.DATA_FILE
destination = tmp_path / "tasks.json"
destination.write_text(source.read_text(encoding="utf-8"), encoding="utf-8")
monkeypatch.setattr(store, "DATA_FILE", destination)
return destination
56 changes: 56 additions & 0 deletions tests/test_api.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import json
from pathlib import Path

from fastapi.testclient import TestClient

from app.main import app


client = TestClient(app)


def task_ids(response) -> list[int]:
assert response.status_code == 200
return [task["id"] for task in response.json()]


def test_list_tasks_without_filters_returns_every_task() -> None:
assert task_ids(client.get("/tasks")) == [1, 2, 3, 4]


def test_list_tasks_filters_by_status() -> None:
assert task_ids(client.get("/tasks", params={"status": "open"})) == [1, 3, 4]
assert task_ids(client.get("/tasks", params={"status": "done"})) == [2]


def test_list_tasks_searches_title_and_description_case_insensitively() -> None:
assert task_ids(client.get("/tasks", params={"q": "LAUNCH"})) == [1]
assert task_ids(client.get("/tasks", params={"q": "shared archive"})) == [4]


def test_list_tasks_combines_status_and_search() -> None:
response = client.get("/tasks", params={"status": "open", "q": "plan"})
assert task_ids(response) == [3]


def test_complete_task_persists_done_state(isolated_data_file: Path) -> None:
complete_response = client.post("/tasks/3/complete")
assert complete_response.status_code == 200
assert complete_response.json()["status"] == "done"
assert complete_response.json()["completed_at"] is not None

stored_tasks = json.loads(isolated_data_file.read_text(encoding="utf-8"))
stored_task = next(task for task in stored_tasks if task["id"] == 3)
assert stored_task["status"] == "done"
assert stored_task["completed_at"] is not None

refreshed_response = client.get("/tasks/3")
assert refreshed_response.status_code == 200
assert refreshed_response.json()["status"] == "done"
assert refreshed_response.json()["completed_at"] is not None


def test_complete_missing_task_returns_not_found() -> None:
response = client.post("/tasks/999/complete")
assert response.status_code == 404
assert response.json() == {"detail": "Task not found"}