reading-list / tests /test_contract.py
pardeep-singh's picture
Deploy reading-list from main 730966e
06b349d verified
Raw History Blame Contribute Delete
10.2 kB
"""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")