"""Black-box contract tests for docs/contract.md. HTTP only, through the `client` fixture (fresh app and empty store per test). No imports from app.store and no access to app.state. """ BOOKS = "/api/books" STATS = "/api/stats" STATUSES = ("to-read", "reading", "done") def add_book(client, **body): """POST a book and return the created Book JSON, asserting 201.""" response = client.post(BOOKS, json=body) assert response.status_code == 201, response.text return response.json() def patch_status(client, book_id, status): return client.patch(f"{BOOKS}/{book_id}", json={"status": status}) def assert_validation_error(response): """422 with FastAPI's default shape: detail is a list of items with loc and msg.""" assert response.status_code == 422, response.text detail = response.json()["detail"] assert isinstance(detail, list) assert len(detail) >= 1 for item in detail: assert "loc" in item assert "msg" in item # --- POST /api/books: success --------------------------------------------------- def test_create_returns_201_with_exact_book_shape(client): response = client.post( BOOKS, json={"title": "Dune", "author": "Frank Herbert", "status": "reading"} ) assert response.status_code == 201 assert response.json() == { "id": 1, "title": "Dune", "author": "Frank Herbert", "status": "reading", } def test_create_minimal_body_applies_defaults(client): response = client.post(BOOKS, json={"title": "Dune"}) assert response.status_code == 201 assert response.json() == { "id": 1, "title": "Dune", "author": None, "status": "to-read", } def test_create_assigns_incrementing_ids(client): ids = [add_book(client, title=f"Book {n}")["id"] for n in range(3)] assert ids == [1, 2, 3] def test_create_trims_title(client): assert add_book(client, title=" Dune ")["title"] == "Dune" def test_create_trims_author(client): assert add_book(client, title="Dune", author=" Frank Herbert ")["author"] == "Frank Herbert" def test_create_blank_author_becomes_null(client): assert add_book(client, title="Dune", author=" ")["author"] is None def test_create_empty_author_becomes_null(client): assert add_book(client, title="Dune", author="")["author"] is None def test_create_explicit_null_author_is_allowed(client): assert add_book(client, title="Dune", author=None)["author"] is None def test_create_accepts_each_status(client): for status in STATUSES: assert add_book(client, title="Dune", status=status)["status"] == status def test_create_accepts_200_character_title(client): title = "a" * 200 assert add_book(client, title=title)["title"] == title def test_create_title_length_is_checked_after_trimming(client): title = "a" * 200 assert add_book(client, title=f" {title} ")["title"] == title def test_create_accepts_200_character_author(client): author = "b" * 200 assert add_book(client, title="Dune", author=author)["author"] == author def test_create_allows_duplicate_titles(client): first = add_book(client, title="Dune") second = add_book(client, title="Dune") assert first["id"] != second["id"] def test_create_ignores_unknown_fields(client): response = client.post(BOOKS, json={"title": "Dune", "rating": 5}) assert response.status_code == 201 assert response.json() == {"id": 1, "title": "Dune", "author": None, "status": "to-read"} # --- POST /api/books: validation ------------------------------------------------ def test_create_rejects_missing_title(client): assert_validation_error(client.post(BOOKS, json={"author": "Frank Herbert"})) def test_create_rejects_empty_title(client): assert_validation_error(client.post(BOOKS, json={"title": ""})) def test_create_rejects_whitespace_only_title(client): assert_validation_error(client.post(BOOKS, json={"title": " "})) def test_create_rejects_201_character_title(client): assert_validation_error(client.post(BOOKS, json={"title": "a" * 201})) def test_create_rejects_non_string_title(client): assert_validation_error(client.post(BOOKS, json={"title": 123})) def test_create_rejects_null_title(client): assert_validation_error(client.post(BOOKS, json={"title": None})) def test_create_rejects_201_character_author(client): assert_validation_error(client.post(BOOKS, json={"title": "Dune", "author": "b" * 201})) def test_create_rejects_invalid_status(client): assert_validation_error(client.post(BOOKS, json={"title": "Dune", "status": "finished"})) def test_create_rejects_malformed_json(client): response = client.post( BOOKS, content=b'{"title": "Dune"', headers={"Content-Type": "application/json"} ) assert_validation_error(response) def test_rejected_create_does_not_store_a_book(client): client.post(BOOKS, json={"title": ""}) response = client.get(BOOKS) assert response.status_code == 200 assert response.json() == [] # --- GET /api/books ------------------------------------------------------------- def test_list_is_empty_array_when_store_is_empty(client): response = client.get(BOOKS) assert response.status_code == 200 assert response.json() == [] def test_list_returns_books_in_insertion_order(client): created = [ add_book(client, title="Dune", author="Frank Herbert", status="reading"), add_book(client, title="Emma", status="done"), add_book(client, title="Ulysses"), ] response = client.get(BOOKS) assert response.status_code == 200 assert response.json() == created def test_list_filters_by_each_status(client): by_status = { status: add_book(client, title=f"Book {status}", status=status) for status in STATUSES } for status, book in by_status.items(): response = client.get(BOOKS, params={"status": status}) assert response.status_code == 200 assert response.json() == [book] def test_list_filter_keeps_insertion_order(client): first = add_book(client, title="A", status="done") add_book(client, title="B", status="reading") third = add_book(client, title="C", status="done") response = client.get(BOOKS, params={"status": "done"}) assert response.json() == [first, third] def test_list_filter_with_no_matches_is_empty_array(client): add_book(client, title="Dune", status="to-read") response = client.get(BOOKS, params={"status": "done"}) assert response.status_code == 200 assert response.json() == [] def test_list_rejects_invalid_status_filter(client): assert_validation_error(client.get(BOOKS, params={"status": "finished"})) # --- PATCH /api/books/{id} ------------------------------------------------------ def test_patch_returns_updated_book(client): add_book(client, title="Dune", author="Frank Herbert", status="reading") response = patch_status(client, 1, "done") assert response.status_code == 200 assert response.json() == { "id": 1, "title": "Dune", "author": "Frank Herbert", "status": "done", } def test_patch_change_persists_in_list(client): add_book(client, title="Dune") patch_status(client, 1, "reading") assert client.get(BOOKS).json()[0]["status"] == "reading" def test_patch_only_changes_the_target_book(client): add_book(client, title="Dune") other = add_book(client, title="Emma") patch_status(client, 1, "done") assert client.get(BOOKS).json()[1] == other def test_patch_unknown_id_returns_404_book_not_found(client): response = patch_status(client, 999, "done") assert response.status_code == 404 assert response.json() == {"detail": "Book not found"} def test_patch_unknown_id_returns_404_when_other_books_exist(client): add_book(client, title="Dune") response = patch_status(client, 2, "done") assert response.status_code == 404 assert response.json() == {"detail": "Book not found"} def test_patch_rejects_invalid_status(client): add_book(client, title="Dune") assert_validation_error(patch_status(client, 1, "finished")) def test_patch_rejects_missing_status(client): add_book(client, title="Dune") assert_validation_error(client.patch(f"{BOOKS}/1", json={})) def test_patch_rejects_non_integer_id(client): assert_validation_error(patch_status(client, "abc", "done")) def test_rejected_patch_leaves_book_unchanged(client): book = add_book(client, title="Dune", status="reading") patch_status(client, 1, "finished") assert client.get(BOOKS).json() == [book] # --- GET /api/stats ------------------------------------------------------------- def test_stats_all_zero_when_empty(client): response = client.get(STATS) assert response.status_code == 200 assert response.json() == { "total": 0, "by_status": {"to-read": 0, "reading": 0, "done": 0}, } def test_stats_counts_after_adds(client): add_book(client, title="A", status="done") add_book(client, title="B", status="done") add_book(client, title="C") response = client.get(STATS) assert response.status_code == 200 assert response.json() == { "total": 3, "by_status": {"to-read": 1, "reading": 0, "done": 2}, } def test_stats_counts_after_patch(client): add_book(client, title="A") add_book(client, title="B") patch_status(client, 2, "reading") assert client.get(STATS).json() == { "total": 2, "by_status": {"to-read": 1, "reading": 1, "done": 0}, } def test_stats_total_equals_sum_of_by_status(client): for n, status in enumerate(STATUSES * 2): add_book(client, title=f"Book {n}", status=status) stats = client.get(STATS).json() assert set(stats["by_status"]) == set(STATUSES) assert stats["total"] == sum(stats["by_status"].values()) == 6 # --- GET / ---------------------------------------------------------------------- def test_index_serves_html(client): response = client.get("/") assert response.status_code == 200 assert response.headers["content-type"].startswith("text/html")