data/sample-service: where the bodies are
64 lines of Python across two files, two business endpoints, five tests, nine assertions, all green. The bug the README hides is fixed and the money is right. Two risks are still open. Every number here came from running the code; the commands are in the footer.
01 Module map
One file owns the app, the store, the math, and the routes. There is no layer boundary to hide behind.
| Path | Lines | What it owns |
|---|---|---|
| app/main.py | 34 | The FastAPI app, the in-memory ORDERS dict (3 orders, 2 accounts), order_total(), and both route handlers. Every risk below lives here. |
| app/__init__.py | 0 | Package marker only. |
| tests/test_orders.py | 30 | 5 tests, 9 assertions: 3 through TestClient, 2 calling order_total() directly against the live ORDERS dict. Two lines longer than it was; see 03. |
| tests/__init__.py | 0 | Package marker only. |
| pytest.ini | 3 | testpaths=tests, pythonpath=.. That is why bare pytest -q works from the repo root. |
| requirements.txt | 3 | fastapi, httpx, pytest, all unpinned. No lockfile, so the versions in the header are what resolved today, not what the repo asks for. |
| README.md | 5 | Says one test fails and asks you to find the bug. Now stale: the bug is fixed and the suite is green. Left as written, because it is the exercise brief, not documentation. |
02 Endpoints, as the app answers them
Ten paths requested through TestClient. The totals column moves when you rewind; the status codes do not. FastAPI also mounts /docs, /redoc and /openapi.json by default, so the two below are the business surface, not the whole route table.
| Request | Status | Response | total |
|---|---|---|---|
| GET/orders/A-1001 | 200 | {id, account, lines[1], total} |
2400.0 |
| GET/orders/A-1002 | 200 | {id, account, lines[2], total} |
1650.0 |
| GET/orders/B-2001 | 200 | lines: [], the empty order |
0.0 |
| GET/orders/nope | 404 | {"detail":"order not found"} |
n/a |
| GET/orders/%20 | 404 | Blank id falls through to the same handler 404. | n/a |
| GET/orders/ | 404 | {"detail":"Not Found"}, the router's 404, not the handler's. |
n/a |
| GET/accounts/Account A/total | 200 | orders: 2 |
4050.0 |
| GET/accounts/Account B/total | 200 | orders: 1, and its only order is empty |
0.0 |
| GET/accounts/account a/total | 404 | Same string, lower case. See risk 3, still open. | n/a |
| GET/accounts/Nope/total | 404 | {"detail":"account not found"} |
n/a |
03 Test summary
Five tests, nine assertions, all green. Two of those assertions are new, and they are the point: the four tests that used to pass did so while the money was wrong.
| Test | Line | Result | What it asserts |
|---|---|---|---|
test_get_order_ok+1 assert |
7 | pass | Status 200, the id, and now the total. This is the assertion that closes the HTTP-boundary gap: it fails on the old code. |
test_get_order_missing | 14 | pass | Unknown id gives 404. |
test_total_two_lines | 18 | pass | The helper-level arithmetic check. It was the only red test, and the fix turned it green. |
test_empty_order_total_is_zero | 22 | pass | Passed either way. On an empty lines list the loop body never runs, so total stays 0.0 whichever operator is in it. |
test_account_total+1 assert |
26 | pass | Order count and now the rolled-up total, 4050.0. Also fails on the old code. |
..... [100%] 5 passed, 2 warnings in 0.31s
FAILED tests/test_orders.py::test_get_order_ok - assert 1202.0 == (2 * 1200.0) FAILED tests/test_orders.py::test_total_two_lines - assert 1256.0 == ((10 * 45.0) ... FAILED tests/test_orders.py::test_account_total - assert 2458.0 == (((2 * 1200.0)... 3 failed, 2 passed, 2 warnings in 0.27s
total field on an endpoint response. Both money paths reached the client unchecked, and one helper-level test was the entire safety net. Reintroducing the bug now turns three tests red instead of one, two of them at the HTTP boundary where a caller actually feels it.
04 The three ranked risks
Ranked by what a wrong answer costs, not by how hard it is to fix. R1 is closed. R2 and R3 are open, and neither was masked by closing R1.
Every non-empty order total was wrong. Fixed.
closedapp/main.py:17 added the line quantity to the unit price instead of multiplying them. It now multiplies, which is what the docstring on line 14 always said. It was never contained: both endpoints compute money through this one helper, so the wrong number reached the order response and the account rollup, and nothing in either response signalled it. Kept here as the record of what it cost while it was live.
total += line["qty"] * line["unit"]
A-1001 2 x 1200.00 = 2400.00 truth, API says 2400.00 (match) A-1002 10 x 45.00 + 1 x 1200.00 = 1650.00 truth, API says 1650.00 (match) Account A rollup = 4050.00 truth, API says 4050.00 (match)
A malformed record is an unhandled 500
highapp/main.py:16-17 reaches into order["lines"], line["qty"] and line["unit"] with raw indexing. Nothing validates the store: no Pydantic model on the way in, no response model on the way out. A row missing a key raises KeyError out of the handler, which FastAPI turns into an unhandled 500 with no useful body. Today the store is a literal, so it holds. Reading a real database or feed, the reach depends on the missing key: a bad qty or unit breaks the orders that contain it and any account rollup that touches them, while a missing account key breaks the scan at app/main.py:31 for every account. A 500 is the honest status for corrupt server-side data; the defect is that it is unhandled and unlogged, so nobody learns which row did it.
order_total({"lines":[{"sku":"X","qty":1}]}) -> KeyError: 'unit'
order_total({"id":"X"}) -> KeyError: 'lines'
order_total({"lines":[{"qty":"2","unit":10.0}]}) -> TypeError: can only concatenate str
order_total({"lines":[{"qty":2,"unit":None}]}) -> TypeError: unsupported operand type
A missing key is not the only shape. A key present with the wrong type raises TypeError from the same line, so the guard has to validate types, not just key presence. The two TypeError messages above are from the old +; the fix changes the wording, not the failure.
Account lookup is an unindexed exact-string scan
mediumapp/main.py:31 filters the entire store on exact string equality, with the account name arriving as free text in the path. Three consequences. Matching is case-sensitive and untrimmed, verified below, and nothing in the code or tests states whether that is intended, so the identity rule is undefined rather than merely strict. There is no account registry: an account exists only as a string on an order, so the 404 at app/main.py:33 is the only possible answer for a name with no orders, and the endpoint cannot distinguish a typo from a real but empty account. And the scan is O(n) per request over the whole store, which is free at 3 orders and not free once the store is a table.
GET /accounts/Account A/total -> 200 {"account":"Account A","orders":2,...}
GET /accounts/account a/total -> 404 {"detail":"account not found"}
05 The fix, applied
One character in the app, two assertions in the tests. Applied to data/sample-service/ on disk, not to a scratch copy: the green suite above is the repo's real state.
@@ -13,7 +13,7 @@ def order_total(order: dict) -> float: """Sum of qty * unit across lines. Empty orders total 0.""" total = 0.0 for line in order["lines"]: - total += line["qty"] + line["unit"] + total += line["qty"] * line["unit"] return round(total, 2)
@@ -7,6 +7,7 @@ def test_get_order_ok(): r = client.get("/orders/A-1001") assert r.status_code == 200 assert r.json()["id"] == "A-1001" + assert r.json()["total"] == 2 * 1200.0 @@ -25,3 +26,4 @@ def test_account_total(): r = client.get("/accounts/Account A/total") assert r.status_code == 200 assert r.json()["orders"] == 2 + assert r.json()["total"] == 2 * 1200.0 + 10 * 45.0 + 1 * 1200.0
1 failed, 4 passed to 5 passed; the three money endpoints returned 2400.0, 1650.0 and 4050.0, matching the hand arithmetic in R1; then the fix was reverted in a scratch copy with the new tests kept, and three tests went red. The last step is the one that matters, because a new assertion that cannot fail is not a test.
- Still open: R2, R3, and the unbounded response in 06. None is a one-liner.
- Not addressed: float arithmetic on money.
round(total, 2)papers over it at 3 orders and will not at scale. README.mdstill says one test fails. It is the exercise brief, so I left it; say the word and it gets a line about the fix.
06 What I threw at it
Bad, empty and huge input, against the service and against this page. Most of it held. One thing did not, and it is not in the three ranked risks because I only found it here.
| Thrown at it | Result | Read |
|---|---|---|
Bad input. 8,000-character order id; ../../etc/passwd; %00; a <script> tag; ' OR 1=1; a unicode account name; a trailing path segment |
404 on all nine | Held. Path params are dict keys and list comparisons, never a query language, so there is nothing to inject into. The 404 body is a fixed string, so nothing reflects back either. |
Empty input. Emptied the ORDERS store, then requested both endpoints. Also order_total on {"lines": []} and on {} |
404, 404, 0.0, KeyError |
Held, except the bare {} case, which is R2 again. An empty store degrades to 404 rather than 500, which is the right shape. |
Huge input. One order with 500,000 lines, then GET /orders/H-9999 |
200, a 16.0 MB body in 1.94 s | Did not hold, still open. The handler at app/main.py:26 spreads the whole order into the response, so lines is echoed back unbounded. No cap, no pagination, no Content-Length guard. The math is fast (0.03 s); serializing is the cost. One caller can pull a 16 MB response per request. |
| The page. Toggled the rewind switch both directions repeatedly, reloaded, ran it with JavaScript off, parsed the markup, and checked every swap node for a missing counterpart state | No stale values, no script errors, no unclosed tags | Held. Every swap node carries both states, so nothing is left showing the wrong run. Values are written with textContent, so the verbatim pytest output cannot inject markup. With JavaScript off the page pins to the current state and says so. |
lines in the order response, and return a line count plus a first page rather than the whole array. That is the same class of problem as R2 and R3: the response contract is implicit, so nothing bounds it.