diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..b088569 --- /dev/null +++ b/.github/workflows/tests.yml @@ -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 diff --git a/README.md b/README.md index 4807bf8..5be6c5b 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/app/service.py b/app/service.py index 8022b93..ad95948 100644 --- a/app/service.py +++ b/app/service.py @@ -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 @@ -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 diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..6e7c5c4 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,5 @@ +-r requirements.txt + +httpx>=0.28,<1.0 +httpx2>=2.0,<3.0 +pytest>=8.0,<10.0 diff --git a/requirements.txt b/requirements.txt index 27d3872..d22400c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -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" diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..8e68d9c --- /dev/null +++ b/tests/conftest.py @@ -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 diff --git a/tests/test_api.py b/tests/test_api.py new file mode 100644 index 0000000..9465f42 --- /dev/null +++ b/tests/test_api.py @@ -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"}