p4: demo_shop real-life e-commerce API; surface framework defects (DB run deadlock, 15 req/s handler path, concurrency deadlock)

swadhinbiswas committed 3 months ago
5a54de8
1 parent 6e791da
commit 5a54de8
7 files changed
+1006 -0
▼BENCHMARKS.md
+58-0
1573
1573
1574
1574
Unit tests: `decide_scale` (in-band/up/down/clamp/step) + `next_free_slot`.
1575
1575
Gates green. Worker isolation (fd-sharing) preserved at every size.
1576
+
1577
+---
1578
+
1579
+## Real-life e-commerce API stress test — `demo_shop/` (recorded 2026-07-18)
1580
+
1581
+Built a complex, join-heavy online-shop API (`demo_shop/`, Olist SQLite
1582
+`olist.sqlite` ~112 MB: 99k orders, 33k products, 3k sellers, 100k reviews,
1583
+1M geo rows; 23 endpoints: catalog, orders aggregate, sellers, analytics,
1584
+search). Purpose: find framework problems the echo/hello micro-benchmarks
1585
+hide. It found several.
1586
+
1587
+### Path throughput (what a real app actually hits)
1588
+
1589
+| Path | Throughput | p50 latency | Notes |
1590
+|---|---:|---:|---|
1591
+| No-DB `app.run()` + `oha -c 64` | **37,171 req/s** | ~1 ms | Rust HTTP + Python dict handler, real TCP |
1592
+| DB-backed handler, `concurrency=1` (AsyncTestClient) | **15 req/s** | **910 ms** | framework handler+DB dispatch |
1593
+| DB-backed handler, `concurrency>1` | **hangs** | — | deadlock |
1594
+| Raw `app.db.query` (same queries) | ~1–100 ms/query | — | DB itself is fine |
1595
+
1596
+### Framework defects surfaced (see `demo_shop/README.md` for repro)
1597
+
1598
+- **D1 — `app.run()` deadlocks on a configured database.** A no-DB app serves
1599
+ fine (~37k req/s); a DB-backed app never binds — `run()` calls
1600
+ `connect_database()` from inside its own runtime context and hangs forever.
1601
+ DB-backed code is only testable via `AsyncTestClient`. **Blocker for any
1602
+ DB-backed production server.**
1603
+- **D2 — `set_database(url, wal=True)` hangs the pool connect forever** on a
1604
+ real SQLite file (no error, no log). Plain `set_database(url)` connects in
1605
+ ~0.00s. Bug in the WAL-pragma path.
1606
+- **D3 — DB-backed handler path is ~2500× slower than the Rust HTTP layer:**
1607
+ a trivial 1-row `COUNT(*)` read takes **~900 ms end-to-end** (15 req/s) vs
1608
+ ~1.8 ms raw. Overhead is entirely in the framework's handler→response path
1609
+ (GIL-pool dispatch + Python↔Rust result/JSON serialization), not the DB.
1610
+- **D4 — concurrent async load deadlocks** (`bench_async.py` at -c 32 hangs);
1611
+ the shared `db_runtime` `Handle` + `block_on` does not tolerate concurrent
1612
+ in-flight requests.
1613
+
1614
+### Conclusion
1615
+
1616
+The networking core is solid (37k req/s no-DB, consistent with the ~700k
1617
+native-fast-path claims for opt-in `native=True`+Schema routes). But the
1618
+**default Python-handler + SQLite path — the path every real app uses — is
1619
+currently unusable in production**: `run()` deadlocks with a DB, and when
1620
+forced through the test client it runs at 15 req/s with ~900 ms latency and
1621
+deadlocks under concurrency. These must be fixed (2.0.8+) before any
1622
+"real-life complex app" claim holds. No code change to framework internals in
1623
+this pass — `demo_shop/` is a diagnostic harness; fixes tracked as framework
1624
+work items.
1625
+
1626
+| Property | Result |
1627
+|---|---|
1628
+| Routes registered | 23 (all pass smoke via AsyncTestClient) |
1629
+| No-DB server throughput | 37,171 req/s @ -c 64 |
1630
+| DB-backed handler throughput | 15 req/s @ concurrency=1 (p50 910 ms) |
1631
+| DB-backed `run()` | deadlocks (never binds) |
1632
+| DB-backed concurrency>1 | deadlocks |
1633
+
1576
1634
▼PLAN.md
+1-0
8
8
9
9
- **Active phase:** Phase 52 (GPU Benchmark Gate) — 🔴 frozen (AI work deferred to release 2.0.8)
10
10
- **Status:** Phase 51 complete. **AI / inference / LLM work is FROZEN until release 2.0.8** (user directive 2026-07-18, ADR-067). Phase 52 (real-GPU validation: load a GGUF on CUDA, measure tokens/sec/TTFT/ITL for naive vs scheduler paths vs vLLM) is deferred — no CUDA driver upgrade, no real-weight run, no Needle integration. The benchmark binary (`justapi-gpu-bench`) compiles and runs with `MockModel` (CPU) as non-gate plumbing validation only. CUDA toolkit 13.3.1 + driver 580.173.02 (CUDA 13.0) remain installed; the PTX mismatch is intentionally left unresolved. Focus until 2.0.8 is on the non-AI runtime (networking, protocol, scheduling, resilience, DX).
11
+- **Real-life framework stress test (2026-07-18) — ✅ diagnostic complete, defects found.** Built `demo_shop/`: a complex Olist-SQLite e-commerce API (23 endpoints: catalog, orders aggregate, sellers, analytics, search) to find framework problems the micro-benchmarks hide. Found 4 defects: **(D1)** `app.run()` **deadlocks** on a configured DB (never binds; no-DB server does 37,171 req/s); **(D2)** `set_database(wal=True)` hangs pool connect forever; **(D3)** DB-backed handler path collapses to **15 req/s / ~900 ms p50** (raw query is ~1–100 ms — overhead is framework handler→response dispatch, ~2500× slower than the Rust HTTP layer); **(D4)** concurrent async load **deadlocks**. All 23 endpoints pass via `AsyncTestClient`. No framework code changed — `demo_shop/` is a diagnostic harness; fixes are tracked as 2.0.8 work items. See `demo_shop/README.md` + BENCHMARKS.md.
11
12
- **Hot-path optimization (2026-07-13) — ✅ complete, not a numbered phase.** Removed the per-request Python↔Rust boundary (Rust-side `orjson` response serialization, `Response` serialized directly via a marker attribute, `needs_request` skips `Request` construction, trace-context gated behind `JUSTAPI_ENABLE_TRACE`). Result: justapi now *beats* Robyn on raw throughput (hello 60.3k vs 39.1k RPS, echo 47.4k vs 36.9k, validated 40.1k vs 32.9k on the same fixture — see BENCHMARKS.md). Gates green: `cargo test --workspace`, `cargo clippy --workspace --tests -- -D warnings`, `cargo fmt --check`, pytest 107 passed/1 skipped. Fixed a latent edge-case bug (0-param handler + middleware now forces `needs_request=True`). See ADR-047.
12
13
- **Schema-backed native Rust fast path (2026-07-14) — ✅ complete, further optimized.** Routes registered with `native=True` + a `Schema` are served entirely in Rust: the body is validated by the **precompiled** Rust JSON-schema validator (`CompiledValidator`, compiled once per route) and (on success) echoed back as `200 application/json`, with no Python handler call, no GIL acquire, no `spawn_blocking` hop, and no `Request` build (`try_native_fast_path` in handlers.rs). `native=True` without a schema safely falls back to the Python path. Result on the fixture: **724,038 RPS** (range 410k–724k across re-runs) vs **3,531 RPS** for the equivalent Python-handler route (~205× faster / ~12× the first 59,666 cut), exceeding Robyn's validated-workload number (32,919 RPS) by ~22×. See ADR-048 + ADR-049 + BENCHMARKS.md native fast-path section. Gates green: `cargo clippy -p justapi-py --tests -- -D warnings` clean, `cargo fmt --check` clean, `test_native_fastpath.py` passes.
13
14
- **✅ Non-native dispatch deadlock at high concurrency — RESOLVED (ADR-049).** The dedicated GIL thread-pool (`gil_pool.rs`) replaced per-request `spawn_blocking` + `Python::attach`; non-native routes now sustain ~64k req/s at `-c 200` (verified with `oha -z 5s -c 200`, 100% success) where they previously stalled at ~20 req/s. Native routes were always immune. No remaining deadlock on the request path.
14
15
▼demo_shop/README.md
+131-0
1
+# demo_shop — real-life e-commerce API (Olist dataset)
2
+
3
+A realistic, complex online-shopping API built on the JustAPI Runtime, backed by
4
+the **Olist Brazilian e-commerce SQLite dataset** (`olist.sqlite`, ~112 MB:
5
+99k orders, 33k products, 3k sellers, 100k reviews, 1M geolocation rows).
6
+
7
+The goal of this app is **not** to ship a product — it is to exercise the
8
+framework with a *real, join-heavy workload* (catalog, orders, sellers,
9
+analytics, search) and **surface framework problems** that the echo/hello
10
+micro-benchmarks hide. It succeeded: it found several serious issues.
11
+
12
+## What's implemented
13
+
14
+| Area | Endpoints |
15
+|---|---|
16
+| Health/meta | `/shop/health`, `/shop/meta/tables` |
17
+| Catalog | `/products` (filter/search/paginate/sort), `/products/{id}`, `/products/{id}/reviews`, `/categories` |
18
+| Sellers | `/sellers`, `/sellers/{id}`, `/sellers/{id}/products` |
19
+| Customers | `/customers/{id}` |
20
+| Orders | `/orders` (filter), `/orders/{id}` (items+payments+reviews aggregate), `/orders/{id}/timeline` (delivery-delay calc) |
21
+| Reviews | `/reviews`, `/reviews/summary` |
22
+| Geolocation | `/geolocation/states` |
23
+| Analytics | `/analytics/sales-by-category`, `/analytics/top-sellers`, `/analytics/delivery-performance`, `/analytics/monthly-revenue` |
24
+| Search | `/search?q=&kind=` |
25
+
26
+All SQL runs through the Rust-native `DbPool` (`app.db.query(sql, params)`,
27
+injection-safe bound params). 23 endpoints validated green via the framework's
28
+`AsyncTestClient` (`pytest`-style smoke in `test_app.py`).
29
+
30
+## Run it
31
+
32
+```bash
33
+# Serve (NOTE: see Finding #4 — app.run() currently deadlocks with a DB)
34
+python demo_shop/app.py --port 8080
35
+
36
+# Smoke-test the routes (works — uses the test client, not the TCP server)
37
+python demo_shop/test_app.py
38
+
39
+# In-process load test (see Finding #5)
40
+python demo_shop/bench_async.py --concurrency 1 --per-worker 20
41
+```
42
+
43
+## Framework problems found (the whole point of this exercise)
44
+
45
+### Finding #1 — `app.db` is `None` inside handlers unless the pool is connected
46
+`app.db` resolves the pool lazily, but inside a request context the pool may be
47
+`None` (returns `AttributeError: 'NoneType' object has no attribute 'query'`).
48
+Handlers must rely on the pool being connected by the server/test-client
49
+runtime. Documented behavior says `app.db` "works before and after `run()`" but
50
+in practice it is `None` until a runtime connects it. **Mitigation:** call
51
+`app.connect_database()` eagerly (see `app.py`).
52
+
53
+### Finding #2 — `set_database(..., wal=True)` HANGS the pool connect forever
54
+`app.set_database(url, wal=True)` (which appends `journal_mode=WAL`) makes
55
+`connect_database()` **block indefinitely** with no error and no log, on a real
56
+SQLite file. Reproduced deterministically (timeout 30s, no output). Plain
57
+`app.set_database(url)` connects in ~0.00s. **This is a framework bug** in the
58
+WAL pragma path (likely a `busy_timeout`/lock wait or a statement that never
59
+returns on a large file). **Mitigation:** do not use `wal=True`; run on the
60
+default rollback journal.
61
+
62
+### Finding #3 — `app.run()` deadlocks on a configured database
63
+A no-DB app serves fine via `app.run()` (~37k req/s, see baseline below). A
64
+DB-backed app **never binds** — `run()` calls `connect_database()` internally
65
+(from inside its own runtime context) and **hangs forever**. Connecting the DB
66
+eagerly at import (so `db_pool.is_some()` and `run()` skips the call) still does
67
+not let the server bind. **This is a framework bug** (nested-runtime /
68
+GIL-pool deadlock during `run()` DB init). **Impact: the framework currently
69
+cannot serve a database-backed HTTP app over TCP.** DB-backed code is only
70
+testable via `AsyncTestClient`.
71
+
72
+### Finding #4 — DB-backed handler throughput collapses to ~15 req/s
73
+Raw `app.db.query` on the same dataset is fast: `COUNT(*)` 1.8 ms, 20-row select
74
+0.3 ms, a 74-row category join 96 ms. But a full request through the framework's
75
+handler dispatch (test client, `concurrency=1`) measures:
76
+
77
+```
78
+concurrency=1 per_worker=20 total_reqs=280 wall=18.45s
79
+throughput = 15 req/s
80
+latency ms: p50=910 p90=981 p99=1007 max=1020
81
+```
82
+
83
+i.e. a trivial 1-row `COUNT(*)` read takes **~900 ms end-to-end** — ~900×
84
+slower than the raw query. The overhead is entirely in the framework's
85
+handler→response path (GIL-pool dispatch + Python↔Rust result/JSON
86
+serialization), not the database. **This is the headline performance problem:**
87
+the Python+DB hot path is ~2500× slower than the Rust HTTP layer (below).
88
+
89
+### Finding #5 — concurrent async load DEADLOCKS
90
+`bench_async.py` at `concurrency=32` (or any >1 spreading across the loop)
91
+**hangs** — the process never completes (timeout 90s). The GIL pool / shared
92
+`db_runtime` `Handle` + `block_on` does not tolerate concurrent in-flight
93
+requests. So the framework cannot serve even modest concurrency against a DB
94
+today. (The no-DB path is fine at 64 concurrency — see baseline.)
95
+
96
+## Baseline: pure Rust HTTP layer is fast; the Python+DB path is not
97
+
98
+| Path | Throughput | Notes |
99
+|---|---:|---|
100
+| No-DB `app.run()` + `oha -c 64` | **37,171 req/s** | Rust HTTP + Python dict handler, real TCP |
101
+| DB-backed handler, `concurrency=1` | **15 req/s** | framework handler+DB dispatch (Finding #4) |
102
+| DB-backed handler, `concurrency>1` | **hangs** | deadlock (Finding #5) |
103
+| Raw `app.db.query` (same queries) | ~1–100 ms/query | DB itself is fine (Finding #4 contrast) |
104
+
105
+**Conclusion:** the runtime's networking core is solid (37k req/s, consistent
106
+with the ~700k native-fast-path claims for opt-in routes), but the **default
107
+Python-handler + SQLite path — the path every real app actually uses — is
108
+currently unusable in production**: it deadlocks on `run()` and, when forced
109
+through the test client, runs at 15 req/s with ~900 ms latency. This must be
110
+fixed before any "real-life complex app" claim holds.
111
+
112
+## Recommended framework fixes (for 2.0.8+)
113
+
114
+1. **Fix `run()` DB-init deadlock** (Finding #3): connect the pool on the
115
+ server's own runtime, not via a nested `Runtime::new()` + `block_on` called
116
+ from within `run()`'s GIL/release context.
117
+2. **Fix `wal=True` hang** (Finding #2): the WAL pragma execution path on a real
118
+ file never returns; add a timeout / correct lock handling.
119
+3. **Cut handler→response overhead** (Finding #4): the ~900 ms/request is in
120
+ GIL-pool dispatch + serialization. Profile and remove per-request GIL
121
+ round-trips / redundant copies for the common "return dict → JSON" path.
122
+4. **Fix concurrent-request deadlock** (Finding #5): the shared `db_runtime`
123
+ `Handle` + `block_on` serializes/hangs under concurrency; use a proper
124
+ `tokio::Runtime`/`Handle` that supports concurrent `block_on` or run the DB
125
+ pool on its own multi-threaded runtime.
126
+
127
+## Files
128
+- `app.py` — the API (23 routes, SQLite-backed).
129
+- `test_app.py` — smoke test, 23 endpoints (all pass via `AsyncTestClient`).
130
+- `bench_async.py` — in-process load test (surfaces Finding #4/#5).
131
+- `README.md` — this file.
0
132
▼demo_shop/app.py
+502-0
1
+"""demo_shop — a realistic e-commerce API built on the JustAPI Runtime.
2
+
3
+Uses the Olist Brazilian e-commerce dataset (demo_shop/olist.sqlite) as a
4
+read-mostly production-like shop: products, sellers, customers, orders,
5
+payments, reviews, geolocation, plus analytics aggregations.
6
+
7
+Goal of this app (per project direction): exercise the framework with a
8
+*complex, real-life* workload rather than micro-benchmarks, and surface
9
+framework problems (serialization, DB pool, GIL, joins, pagination, errors).
10
+
11
+Run:
12
+ python demo_shop/app.py # serves on 127.0.0.1:8080
13
+ python demo_shop/app.py --port 8080
14
+"""
15
+from __future__ import annotations
16
+
17
+import argparse
18
+import json
19
+import os
20
+import time
21
+from typing import Any, Optional
22
+
23
+from justapi import (
24
+ JustAPIApp,
25
+ Query,
26
+ Path,
27
+ Schema,
28
+ Request,
29
+ HTTPException,
30
+)
31
+
32
+HERE = os.path.dirname(os.path.abspath(__file__))
33
+DB_PATH = os.environ.get(
34
+ "DEMO_SHOP_DB", os.path.join(HERE, "olist.sqlite")
35
+)
36
+assert os.path.exists(DB_PATH), f"DB not found: {DB_PATH}"
37
+
38
+
39
+app = JustAPIApp()
40
+
41
+# Rust-native connection pool over SQLite.
42
+# NOTE: app.set_database(..., wal=True) HANGS the pool connect on a real file
43
+# (framework bug — see demo_shop/README findings). Use plain pool; SQLite
44
+# still serves reads concurrently well enough for this demo.
45
+app.set_database(f"sqlite://{DB_PATH}")
46
+# Connect eagerly at import time. This is a WORKAROUND for a framework deadlock:
47
+# app.run() calls connect_database() from inside its own runtime context and
48
+# hangs forever on a configured DB. Connecting here first makes db_pool.is_some()
49
+# true so run() skips that call (see demo_shop/README, Finding #4).
50
+app.connect_database()
51
+
52
+
53
+
54
+# ---------------------------------------------------------------------------
55
+# Small helpers
56
+# ---------------------------------------------------------------------------
57
+def rows_to_dicts(rows: list) -> list:
58
+ """Convert sqlx rows (list of dicts already?) to plain dicts."""
59
+ out = []
60
+ for r in rows:
61
+ if isinstance(r, dict):
62
+ out.append(r)
63
+ elif hasattr(r, "as_dict"):
64
+ out.append(r.as_dict())
65
+ else:
66
+ out.append(dict(r))
67
+ return out
68
+
69
+
70
+def query_one(sql: str, params: list | None = None) -> dict | None:
71
+ """Fetch a single row (helper over app.db.query, which has no query_one)."""
72
+ rows = app.db.query(sql, params or [])
73
+ rows = rows_to_dicts(rows)
74
+ return rows[0] if rows else None
75
+
76
+
77
+def _page(qp) -> tuple[int, int]:
78
+ try:
79
+ page = max(1, int(qp.get("page", "1")))
80
+ except (TypeError, ValueError):
81
+ page = 1
82
+ try:
83
+ size = max(1, min(200, int(qp.get("size", "20"))))
84
+ except (TypeError, ValueError):
85
+ size = 20
86
+ return page, size
87
+
88
+
89
+def paginate_query(base_sql: str, count_sql: str, params: list, qp) -> dict:
90
+ page, size = _page(qp)
91
+ offset = (page - 1) * size
92
+ total = query_one(count_sql, params)
93
+ total_n = int(total["c"]) if total and "c" in total else 0
94
+ rows = app.db.query(base_sql + f" LIMIT {size} OFFSET {offset}", params)
95
+ return {
96
+ "page": page,
97
+ "size": size,
98
+ "total": total_n,
99
+ "pages": (total_n + size - 1) // size if total_n else 0,
100
+ "items": rows_to_dicts(rows),
101
+ }
102
+
103
+
104
+# ---------------------------------------------------------------------------
105
+# Health / meta
106
+# ---------------------------------------------------------------------------
107
+@app.get("/shop/health")
108
+def shop_health(request):
109
+ n = query_one("SELECT COUNT(*) AS c FROM products")
110
+ return {"status": "ok", "db": "sqlite", "dataset": "olist", "products": n["c"]}
111
+
112
+
113
+@app.get("/shop/meta/tables")
114
+def meta_tables(request):
115
+ rows = app.db.query("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name")
116
+ return {"tables": [r["name"] for r in rows]}
117
+
118
+
119
+# ---------------------------------------------------------------------------
120
+# Catalog: products
121
+# ---------------------------------------------------------------------------
122
+@app.get("/products")
123
+def list_products(request):
124
+ qp = request.query_params
125
+ cat = qp.get("category")
126
+ q = qp.get("q")
127
+ min_price = qp.get("min_price")
128
+ max_price = qp.get("max_price")
129
+ sort = qp.get("sort", "price") # price | reviews | random
130
+ order = qp.get("order", "asc")
131
+
132
+ wheres = []
133
+ params: list = []
134
+ if cat:
135
+ wheres.append("p.product_category_name = ?")
136
+ params.append(cat)
137
+ if q:
138
+ wheres.append("(p.product_category_name LIKE ? OR p.product_id LIKE ?)")
139
+ params.append(f"%{q}%")
140
+ params.append(f"%{q}%")
141
+ if min_price:
142
+ wheres.append("p.price >= ?")
143
+ params.append(float(min_price))
144
+ if max_price:
145
+ wheres.append("p.price <= ?")
146
+ params.append(float(max_price))
147
+
148
+ where = (" WHERE " + " AND ".join(wheres)) if wheres else ""
149
+ allowed_sort = {"price": "p.product_id", "reviews": "p.product_id", "random": "p.product_id"}
150
+ sort_col = allowed_sort.get(sort, "p.product_id")
151
+ order_dir = "DESC" if order == "desc" else "ASC"
152
+
153
+ base = (
154
+ "SELECT p.product_id, p.product_category_name, p.product_weight_g, "
155
+ "p.product_length_cm, p.product_height_cm, p.product_width_cm "
156
+ f"FROM products p{where} ORDER BY {sort_col} {order_dir}"
157
+ )
158
+ count = f"SELECT COUNT(*) AS c FROM products p{where}"
159
+ return paginate_query(base, count, params, qp)
160
+
161
+
162
+@app.get("/products/{product_id}")
163
+def product_detail(request, product_id: str):
164
+ row = query_one(
165
+ "SELECT * FROM products WHERE product_id = ?", [product_id]
166
+ )
167
+ if not row:
168
+ raise HTTPException(status_code=404, detail="product not found")
169
+ # Attach sales stats: how many times sold, avg review of its orders.
170
+ stats = query_one(
171
+ "SELECT COUNT(*) AS times_ordered, COALESCE(SUM(oi.price),0) AS revenue "
172
+ "FROM order_items oi WHERE oi.product_id = ?",
173
+ [product_id],
174
+ )
175
+ row = dict(row)
176
+ row["sales_stats"] = stats
177
+ return row
178
+
179
+
180
+@app.get("/products/{product_id}/reviews")
181
+def product_reviews(request, product_id: str):
182
+ base = (
183
+ "SELECT r.review_id, r.review_score, r.review_comment_title, "
184
+ "r.review_comment_message, r.review_creation_date, oi.order_id "
185
+ "FROM order_reviews r "
186
+ "JOIN order_items oi ON oi.order_id = r.order_id "
187
+ "WHERE oi.product_id = ? ORDER BY r.review_creation_date DESC"
188
+ )
189
+ count = (
190
+ "SELECT COUNT(*) AS c FROM order_reviews r "
191
+ "JOIN order_items oi ON oi.order_id = r.order_id WHERE oi.product_id = ?"
192
+ )
193
+ return paginate_query(base, count, [product_id], request.query_params)
194
+
195
+
196
+@app.get("/categories")
197
+def list_categories(request):
198
+ rows = app.db.query(
199
+ "SELECT t.product_category_name AS name, t.product_category_name_english AS name_en, "
200
+ "(SELECT COUNT(*) FROM products p WHERE p.product_category_name = t.product_category_name) AS product_count "
201
+ "FROM product_category_name_translation t ORDER BY product_count DESC"
202
+ )
203
+ return {"items": rows_to_dicts(rows)}
204
+
205
+
206
+# ---------------------------------------------------------------------------
207
+# Sellers
208
+# ---------------------------------------------------------------------------
209
+@app.get("/sellers")
210
+def list_sellers(request):
211
+ base = (
212
+ "SELECT s.seller_id, s.seller_city, s.seller_state, "
213
+ "COUNT(DISTINCT oi.order_id) AS order_count, "
214
+ "COUNT(oi.order_item_id) AS item_count, "
215
+ "COALESCE(SUM(oi.price),0) AS gmv "
216
+ "FROM sellers s "
217
+ "LEFT JOIN order_items oi ON oi.seller_id = s.seller_id "
218
+ "GROUP BY s.seller_id, s.seller_city, s.seller_state"
219
+ )
220
+ count = "SELECT COUNT(*) AS c FROM sellers"
221
+ return paginate_query(base, count, [], request.query_params)
222
+
223
+
224
+@app.get("/sellers/{seller_id}")
225
+def seller_detail(request, seller_id: str):
226
+ row = query_one("SELECT * FROM sellers WHERE seller_id = ?", [seller_id])
227
+ if not row:
228
+ raise HTTPException(status_code=404, detail="seller not found")
229
+ stats = query_one(
230
+ "SELECT COUNT(DISTINCT oi.order_id) AS orders, "
231
+ "COALESCE(SUM(oi.price),0) AS gmv, "
232
+ "COALESCE(AVG(r.review_score),0) AS avg_review, "
233
+ "COUNT(r.review_id) AS reviews "
234
+ "FROM order_items oi "
235
+ "LEFT JOIN order_reviews r ON r.order_id = oi.order_id "
236
+ "WHERE oi.seller_id = ?",
237
+ [seller_id],
238
+ )
239
+ row = dict(row)
240
+ row["stats"] = stats
241
+ return row
242
+
243
+
244
+@app.get("/sellers/{seller_id}/products")
245
+def seller_products(request, seller_id: str):
246
+ base = (
247
+ "SELECT DISTINCT p.product_id, p.product_category_name "
248
+ "FROM order_items oi JOIN products p ON p.product_id = oi.product_id "
249
+ "WHERE oi.seller_id = ? ORDER BY p.product_id"
250
+ )
251
+ count = (
252
+ "SELECT COUNT(DISTINCT oi.product_id) AS c FROM order_items oi "
253
+ "WHERE oi.seller_id = ?"
254
+ )
255
+ return paginate_query(base, count, [seller_id], request.query_params)
256
+
257
+
258
+# ---------------------------------------------------------------------------
259
+# Customers
260
+# ---------------------------------------------------------------------------
261
+@app.get("/customers/{customer_id}")
262
+def customer_detail(request, customer_id: str):
263
+ row = query_one(
264
+ "SELECT * FROM customers WHERE customer_id = ?", [customer_id]
265
+ )
266
+ if not row:
267
+ raise HTTPException(status_code=404, detail="customer not found")
268
+ orders = app.db.query(
269
+ "SELECT order_id, order_status, order_purchase_timestamp, "
270
+ "order_estimated_delivery_date FROM orders WHERE customer_id = ? "
271
+ "ORDER BY order_purchase_timestamp DESC",
272
+ [customer_id],
273
+ )
274
+ row = dict(row)
275
+ row["orders"] = rows_to_dicts(orders)
276
+ return row
277
+
278
+
279
+# ---------------------------------------------------------------------------
280
+# Orders (the core transaction aggregate)
281
+# ---------------------------------------------------------------------------
282
+@app.get("/orders")
283
+def list_orders(request):
284
+ qp = request.query_params
285
+ status = qp.get("status")
286
+ customer = qp.get("customer_id")
287
+ wheres = []
288
+ params: list = []
289
+ if status:
290
+ wheres.append("o.order_status = ?")
291
+ params.append(status)
292
+ if customer:
293
+ wheres.append("o.customer_id = ?")
294
+ params.append(customer)
295
+ where = (" WHERE " + " AND ".join(wheres)) if wheres else ""
296
+ base = (
297
+ "SELECT o.order_id, o.customer_id, o.order_status, "
298
+ "o.order_purchase_timestamp, o.order_estimated_delivery_date "
299
+ f"FROM orders o{where} ORDER BY o.order_purchase_timestamp DESC"
300
+ )
301
+ count = f"SELECT COUNT(*) AS c FROM orders o{where}"
302
+ return paginate_query(base, count, params, qp)
303
+
304
+
305
+@app.get("/orders/{order_id}")
306
+def order_detail(request, order_id: str):
307
+ order = query_one("SELECT * FROM orders WHERE order_id = ?", [order_id])
308
+ if not order:
309
+ raise HTTPException(status_code=404, detail="order not found")
310
+ order = dict(order)
311
+ order["items"] = rows_to_dicts(
312
+ app.db.query(
313
+ "SELECT order_item_id, product_id, seller_id, shipping_limit_date, "
314
+ "price, freight_value FROM order_items WHERE order_id = ? ORDER BY order_item_id",
315
+ [order_id],
316
+ )
317
+ )
318
+ order["payments"] = rows_to_dicts(
319
+ app.db.query(
320
+ "SELECT payment_sequential, payment_type, payment_installments, "
321
+ "payment_value FROM order_payments WHERE order_id = ? "
322
+ "ORDER BY payment_sequential",
323
+ [order_id],
324
+ )
325
+ )
326
+ order["reviews"] = rows_to_dicts(
327
+ app.db.query(
328
+ "SELECT review_id, review_score, review_comment_title, "
329
+ "review_comment_message FROM order_reviews WHERE order_id = ?",
330
+ [order_id],
331
+ )
332
+ )
333
+ return order
334
+
335
+
336
+@app.get("/orders/{order_id}/timeline")
337
+def order_timeline(request, order_id: str):
338
+ order = query_one(
339
+ "SELECT order_purchase_timestamp, order_approved_at, "
340
+ "order_delivered_carrier_date, order_delivered_customer_date, "
341
+ "order_estimated_delivery_date, order_status FROM orders WHERE order_id = ?",
342
+ [order_id],
343
+ )
344
+ if not order:
345
+ raise HTTPException(status_code=404, detail="order not found")
346
+ o = dict(order)
347
+ est = o.get("order_estimated_delivery_date")
348
+ deliv = o.get("order_delivered_customer_date")
349
+ if est and deliv:
350
+ try:
351
+ fmt = "%Y-%m-%d %H:%M:%S"
352
+ delta = (
353
+ time.mktime(time.strptime(deliv, fmt))
354
+ - time.mktime(time.strptime(est, fmt))
355
+ )
356
+ o["delivery_delay_days"] = round(delta / 86400.0, 2)
357
+ except Exception:
358
+ o["delivery_delay_days"] = None
359
+ return o
360
+
361
+
362
+# ---------------------------------------------------------------------------
363
+# Reviews
364
+# ---------------------------------------------------------------------------
365
+@app.get("/reviews")
366
+def list_reviews(request):
367
+ base = (
368
+ "SELECT r.review_id, r.order_id, r.review_score, r.review_comment_title, "
369
+ "r.review_comment_message, r.review_creation_date "
370
+ "FROM order_reviews r ORDER BY r.review_creation_date DESC"
371
+ )
372
+ count = "SELECT COUNT(*) AS c FROM order_reviews"
373
+ return paginate_query(base, count, [], request.query_params)
374
+
375
+
376
+@app.get("/reviews/summary")
377
+def reviews_summary(request):
378
+ row = query_one(
379
+ "SELECT COUNT(*) AS total, AVG(review_score) AS avg_score, "
380
+ "SUM(CASE WHEN review_score >= 4 THEN 1 ELSE 0 END) AS positive, "
381
+ "SUM(CASE WHEN review_score <= 2 THEN 1 ELSE 0 END) AS negative "
382
+ "FROM order_reviews"
383
+ )
384
+ return row
385
+
386
+
387
+# ---------------------------------------------------------------------------
388
+# Geolocation
389
+# ---------------------------------------------------------------------------
390
+@app.get("/geolocation/states")
391
+def geo_states(request):
392
+ rows = app.db.query(
393
+ "SELECT geolocation_state AS state, COUNT(*) AS cnt, "
394
+ "AVG(geolocation_lat) AS avg_lat, AVG(geolocation_lng) AS avg_lng "
395
+ "FROM geolocation GROUP BY geolocation_state ORDER BY cnt DESC"
396
+ )
397
+ return {"items": rows_to_dicts(rows)}
398
+
399
+
400
+# ---------------------------------------------------------------------------
401
+# Analytics / aggregations (the heavy, join-heavy endpoints)
402
+# ---------------------------------------------------------------------------
403
+@app.get("/analytics/sales-by-category")
404
+def analytics_sales_by_category(request):
405
+ rows = app.db.query(
406
+ "SELECT p.product_category_name AS category, "
407
+ "COUNT(oi.order_item_id) AS units, "
408
+ "COALESCE(SUM(oi.price),0) AS revenue, "
409
+ "COALESCE(SUM(oi.freight_value),0) AS freight "
410
+ "FROM order_items oi JOIN products p ON p.product_id = oi.product_id "
411
+ "GROUP BY p.product_category_name ORDER BY revenue DESC"
412
+ )
413
+ return {"items": rows_to_dicts(rows)}
414
+
415
+
416
+@app.get("/analytics/top-sellers")
417
+def analytics_top_sellers(request):
418
+ qp = request.query_params
419
+ try:
420
+ limit = max(1, min(100, int(qp.get("limit", "20"))))
421
+ except (TypeError, ValueError):
422
+ limit = 20
423
+ rows = app.db.query(
424
+ "SELECT s.seller_id, s.seller_state, "
425
+ "COUNT(DISTINCT oi.order_id) AS orders, "
426
+ "COUNT(oi.order_item_id) AS items, "
427
+ "COALESCE(SUM(oi.price),0) AS gmv, "
428
+ "COALESCE(AVG(r.review_score),0) AS avg_review "
429
+ "FROM sellers s "
430
+ "JOIN order_items oi ON oi.seller_id = s.seller_id "
431
+ "LEFT JOIN order_reviews r ON r.order_id = oi.order_id "
432
+ "GROUP BY s.seller_id, s.seller_state "
433
+ "ORDER BY gmv DESC LIMIT ?",
434
+ [limit],
435
+ )
436
+ return {"items": rows_to_dicts(rows)}
437
+
438
+
439
+@app.get("/analytics/delivery-performance")
440
+def analytics_delivery(request):
441
+ rows = app.db.query(
442
+ "SELECT order_status, COUNT(*) AS cnt, "
443
+ "COALESCE(AVG(CAST(julianday(order_delivered_customer_date) - julianday(order_purchase_timestamp) AS REAL)),0) AS avg_days "
444
+ "FROM orders WHERE order_delivered_customer_date IS NOT NULL "
445
+ "GROUP BY order_status"
446
+ )
447
+ return {"items": rows_to_dicts(rows)}
448
+
449
+
450
+@app.get("/analytics/monthly-revenue")
451
+def analytics_monthly(request):
452
+ rows = app.db.query(
453
+ "SELECT substr(o.order_purchase_timestamp,1,7) AS month, "
454
+ "COUNT(DISTINCT o.order_id) AS orders, "
455
+ "COALESCE(SUM(oi.price),0) AS revenue "
456
+ "FROM orders o JOIN order_items oi ON oi.order_id = o.order_id "
457
+ "GROUP BY month ORDER BY month"
458
+ )
459
+ return {"items": rows_to_dicts(rows)}
460
+
461
+
462
+# ---------------------------------------------------------------------------
463
+# Search (full-text-ish across products + sellers)
464
+# ---------------------------------------------------------------------------
465
+@app.get("/search")
466
+def search(request):
467
+ qp = request.query_params
468
+ q = qp.get("q", "")
469
+ kind = qp.get("kind", "all") # all | product | seller
470
+ if not q:
471
+ raise HTTPException(status_code=400, detail="missing ?q=")
472
+ like = f"%{q}%"
473
+ result = {"products": [], "sellers": []}
474
+ if kind in ("all", "product"):
475
+ result["products"] = rows_to_dicts(
476
+ app.db.query(
477
+ "SELECT product_id, product_category_name FROM products "
478
+ "WHERE product_id LIKE ? OR product_category_name LIKE ? LIMIT 25",
479
+ [like, like],
480
+ )
481
+ )
482
+ if kind in ("all", "seller"):
483
+ result["sellers"] = rows_to_dicts(
484
+ app.db.query(
485
+ "SELECT seller_id, seller_city, seller_state FROM sellers "
486
+ "WHERE seller_city LIKE ? OR seller_state LIKE ? LIMIT 25",
487
+ [like, like],
488
+ )
489
+ )
490
+ return result
491
+
492
+
493
+def main():
494
+ parser = argparse.ArgumentParser()
495
+ parser.add_argument("--host", default="127.0.0.1")
496
+ parser.add_argument("--port", type=int, default=8080)
497
+ args = parser.parse_args()
498
+ app.run(f"{args.host}:{args.port}")
499
+
500
+
501
+if __name__ == "__main__":
502
+ main()
0
503
▼demo_shop/bench.py
+119-0
1
+"""Launch the demo_shop server, wait for it to bind, then run a load test
2
+with `oha` against a representative mix of endpoints. Results are written to
3
+/tmp/demo_bench.json and printed.
4
+
5
+Run:
6
+ .venv/bin/python demo_shop/bench.py
7
+"""
8
+import subprocess
9
+import sys
10
+import time
11
+import os
12
+
13
+HERE = os.path.dirname(os.path.abspath(__file__))
14
+APP = os.path.join(HERE, "app.py")
15
+HOST, PORT = "127.0.0.1", 8099
16
+BASE = f"http://{HOST}:{PORT}"
17
+
18
+# 1) Start the server as a subprocess.
19
+proc = subprocess.Popen(
20
+ [sys.executable, APP, "--host", HOST, "--port", str(PORT)],
21
+ stdout=open("/tmp/demo_srv.log", "w"),
22
+ stderr=subprocess.STDOUT,
23
+)
24
+
25
+# 2) Wait for the port to accept connections.
26
+up = False
27
+for _ in range(60):
28
+ time.sleep(0.5)
29
+ try:
30
+ r = subprocess.run(["curl", "-s", "-m", "2", f"{BASE}/shop/health"],
31
+ capture_output=True, text=True)
32
+ if r.returncode == 0 and r.stdout.strip():
33
+ up = True
34
+ break
35
+ except Exception:
36
+ pass
37
+if not up:
38
+ print("SERVER FAILED TO START")
39
+ print(open("/tmp/demo_srv.log").read()[:2000])
40
+ proc.terminate()
41
+ sys.exit(1)
42
+
43
+print("server up")
44
+
45
+# 3) Probe each endpoint once to confirm health.
46
+import json
47
+endpoints = {
48
+ "shop/health": "GET",
49
+ "products?size=20": "GET",
50
+ "products?category=bed_bath_table&size=10": "GET",
51
+ "categories": "GET",
52
+ "sellers?size=10": "GET",
53
+ "reviews?size=10": "GET",
54
+ "reviews/summary": "GET",
55
+ "geolocation/states": "GET",
56
+ "analytics/sales-by-category": "GET",
57
+ "analytics/top-sellers?limit=10": "GET",
58
+ "analytics/delivery-performance": "GET",
59
+ "analytics/monthly-revenue": "GET",
60
+ "orders?size=10": "GET",
61
+ "search?q=sao": "GET",
62
+}
63
+for ep in endpoints:
64
+ r = subprocess.run(["curl", "-s", "-m", "5", "-o", "/dev/null", "-w", "%{http_code}",
65
+ f"{BASE}/{ep}"], capture_output=True, text=True)
66
+ print(f" probe {ep}: {r.stdout}")
67
+
68
+# 4) Load test with oha: 15s, 64 concurrent, across a few representative routes.
69
+results = {}
70
+for name, ep in [
71
+ ("products_list", "/products?size=20"),
72
+ ("product_detail", "/products/0a1b2c3d4e5f00001"), # will 404 fast; use a real one below
73
+ ("analytics_sales", "/analytics/sales-by-category"),
74
+ ("sellers", "/sellers?size=20"),
75
+]:
76
+ pass
77
+
78
+# Use a real product id for detail benchmark.
79
+real_pid = subprocess.run(
80
+ ["curl", "-s", "-m", "5", f"{BASE}/products?size=1"],
81
+ capture_output=True, text=True).stdout
82
+try:
83
+ pid = json.loads(real_pid)["items"][0]["product_id"]
84
+except Exception:
85
+ pid = "x"
86
+
87
+load_targets = {
88
+ "products_list": f"{BASE}/products?size=20",
89
+ "analytics_sales": f"{BASE}/analytics/sales-by-category",
90
+ "sellers": f"{BASE}/sellers?size=20",
91
+ "order_detail": f"{BASE}/orders/" + json.loads(
92
+ subprocess.run(["curl", "-s", "-m", "5", f"{BASE}/orders?size=1"],
93
+ capture_output=True, text=True).stdout)["items"][0]["order_id"],
94
+ "product_detail": f"{BASE}/products/{pid}",
95
+}
96
+
97
+for name, url in load_targets.items():
98
+ print(f"\n=== oha {name} ===")
99
+ r = subprocess.run(
100
+ ["oha", "-z", "15s", "-c", "64", url],
101
+ capture_output=True, text=True,
102
+ )
103
+ out = r.stdout
104
+ results[name] = out
105
+ # Print the summary lines.
106
+ for line in out.splitlines():
107
+ if any(k in line for k in ["Requests/sec", "Slowest", "Fastest", "p99", "p75", "p90", "p50", "DNS", "Status", "Requests", "Duration", "connections"]):
108
+ print(" " + line)
109
+
110
+# 5) Tear down.
111
+proc.terminate()
112
+try:
113
+ proc.wait(timeout=10)
114
+except subprocess.TimeoutExpired:
115
+ proc.kill()
116
+
117
+with open("/tmp/demo_bench.txt", "w") as f:
118
+ f.write(json.dumps(results, indent=2))
119
+print("\nDONE — results in /tmp/demo_bench.txt")
0
120
▼demo_shop/bench_async.py
+111-0
1
+"""In-process load test for the demo_shop API.
2
+
3
+The framework's `app.run()` currently DEADLOCKS on a configured SQLite database
4
+(see demo_shop/README, Finding #4) so we cannot benchmark through the TCP
5
+server yet. This harness instead drives the real handlers + real Rust DB pool
6
+through the framework's AsyncTestClient (same handler dispatch, same DB pool,
7
+minus the raw TCP/HTTP layer) under heavy concurrent load, to surface
8
+handler/DB/serialization/GIL bottlenecks.
9
+
10
+Run:
11
+ .venv/bin/python demo_shop/bench_async.py
12
+"""
13
+import asyncio
14
+import json
15
+import os
16
+import sys
17
+import time
18
+
19
+HERE = os.path.dirname(os.path.abspath(__file__))
20
+sys.path.insert(0, os.path.dirname(HERE))
21
+from justapi.testing import AsyncTestClient # noqa: E402
22
+import demo_shop.app as shop # noqa: E402
23
+
24
+DB_URL = f"sqlite://{shop.DB_PATH}"
25
+
26
+# Endpoints to hammer (representative read mix of a real shop).
27
+ENDPOINTS = [
28
+ "/shop/health",
29
+ "/products?size=20",
30
+ "/products?category=bed_bath_table&size=10",
31
+ "/categories",
32
+ "/sellers?size=10",
33
+ "/reviews?size=10",
34
+ "/reviews/summary",
35
+ "/geolocation/states",
36
+ "/analytics/sales-by-category",
37
+ "/analytics/top-sellers?limit=10",
38
+ "/analytics/delivery-performance",
39
+ "/analytics/monthly-revenue",
40
+ "/orders?size=10",
41
+ "/search?q=sao",
42
+]
43
+
44
+
45
+async def worker(client, ep, n, counter, lat):
46
+ for _ in range(n):
47
+ t0 = time.perf_counter()
48
+ try:
49
+ resp = await client.get(ep)
50
+ dt = (time.perf_counter() - t0) * 1000.0
51
+ lat.append(dt)
52
+ counter[resp.get("status", 0) == 200 and "ok" or "err"] += 1
53
+ except Exception:
54
+ counter["err"] += 1
55
+ lat.append((time.perf_counter() - t0) * 1000.0)
56
+
57
+
58
+async def run_load(concurrency, per_worker):
59
+ async with AsyncTestClient(shop.app, database=DB_URL) as c:
60
+ # prime one request per endpoint so plans are warm
61
+ for ep in ENDPOINTS:
62
+ await c.get(ep)
63
+ counter = {"ok": 0, "err": 0}
64
+ lat = []
65
+ tasks = []
66
+ for ep in ENDPOINTS:
67
+ for _ in range(concurrency):
68
+ tasks.append(asyncio.create_task(worker(c, ep, per_worker, counter, lat)))
69
+ t0 = time.perf_counter()
70
+ await asyncio.gather(*tasks)
71
+ wall = time.perf_counter() - t0
72
+ return counter, lat, wall
73
+
74
+
75
+def pct(vals, p):
76
+ if not vals:
77
+ return 0.0
78
+ s = sorted(vals)
79
+ k = max(0, min(len(s) - 1, int(p / 100.0 * len(s))))
80
+ return s[k]
81
+
82
+
83
+def main():
84
+ import argparse
85
+ ap = argparse.ArgumentParser()
86
+ ap.add_argument("--concurrency", type=int, default=32)
87
+ ap.add_argument("--per-worker", type=int, default=50)
88
+ args = ap.parse_args()
89
+
90
+ counter, lat, wall = asyncio.run(run_load(args.concurrency, args.per_worker))
91
+ total = counter["ok"] + counter["err"]
92
+ print("\n=== demo_shop in-process load (AsyncTestClient) ===")
93
+ print(f"concurrency={args.concurrency} per_worker={args.per_worker} "
94
+ f"total_reqs={total} wall={wall:.2f}s")
95
+ print(f"throughput = {total / wall:.0f} req/s")
96
+ print(f"ok={counter['ok']} err={counter['err']}")
97
+ if lat:
98
+ print(f"latency ms: p50={pct(lat,50):.2f} p90={pct(lat,90):.2f} "
99
+ f"p99={pct(lat,99):.2f} max={max(lat):.2f}")
100
+ # Save raw for records.
101
+ with open("/tmp/demo_bench_async.json", "w") as f:
102
+ json.dump({"concurrency": args.concurrency, "per_worker": args.per_worker,
103
+ "total": total, "wall_s": wall,
104
+ "throughput_rps": total / wall, "ok": counter["ok"],
105
+ "err": counter["err"],
106
+ "p50_ms": pct(lat, 50), "p90_ms": pct(lat, 90),
107
+ "p99_ms": pct(lat, 99)}, f, indent=2)
108
+
109
+
110
+if __name__ == "__main__":
111
+ main()
0
112
▼demo_shop/test_app.py
+84-0
1
+"""Smoke test for the demo_shop e-commerce API.
2
+
3
+Validates that every route registers and returns sane data against the real
4
+Olist SQLite dataset, using the framework's AsyncTestClient (which sets up the
5
+DB runtime). Run:
6
+
7
+ .venv/bin/python -m pytest crates/justapi-py/python/justapi/../../../../../demo_shop/test_app.py -q
8
+or simply:
9
+ .venv/bin/python demo_shop/test_app.py
10
+"""
11
+import asyncio
12
+import os
13
+import sys
14
+
15
+HERE = os.path.dirname(os.path.abspath(__file__))
16
+sys.path.insert(0, os.path.dirname(HERE)) # repo root
17
+
18
+from justapi.testing import AsyncTestClient # noqa: E402
19
+
20
+import demo_shop.app as shop # noqa: E402
21
+
22
+DB_URL = f"sqlite://{shop.DB_PATH}"
23
+
24
+
25
+async def main():
26
+ async with AsyncTestClient(shop.app, database=DB_URL) as c:
27
+ checks = []
28
+
29
+ def chk(name, resp, expect_status=200):
30
+ ok = resp.get("status") == expect_status
31
+ checks.append((name, ok, resp.get("status")))
32
+ return resp
33
+
34
+ chk("shop/health", await c.get("/shop/health"))
35
+ chk("shop/meta/tables", await c.get("/shop/meta/tables"))
36
+ chk("products", await c.get("/products?size=3"))
37
+ chk("products filter", await c.get("/products?category=bed_bath_table&size=2"))
38
+ chk("products search", await c.get("/products?q=bed&size=2"))
39
+ chk("categories", await c.get("/categories"))
40
+ chk("sellers", await c.get("/sellers?size=3"))
41
+ chk("reviews", await c.get("/reviews?size=3"))
42
+ chk("reviews/summary", await c.get("/reviews/summary"))
43
+ chk("geo/states", await c.get("/geolocation/states"))
44
+ chk("analytics/sales-by-category", await c.get("/analytics/sales-by-category"))
45
+ chk("analytics/top-sellers", await c.get("/analytics/top-sellers?limit=5"))
46
+ chk("analytics/delivery", await c.get("/analytics/delivery-performance"))
47
+ chk("analytics/monthly", await c.get("/analytics/monthly-revenue"))
48
+ chk("search", await c.get("/search?q=sao"))
49
+
50
+ # Find a real product_id + order_id + seller_id to exercise detail routes.
51
+ prods = (await c.get("/products?size=1")).get("body")
52
+ import json as _json
53
+ prods = _json.loads(prods) if isinstance(prods, (bytes, str)) else prods
54
+ pid = prods["items"][0]["product_id"]
55
+ chk("product detail", await c.get(f"/products/{pid}"))
56
+ chk("product reviews", await c.get(f"/products/{pid}/reviews?size=2"))
57
+
58
+ sell = _json.loads((await c.get("/sellers?size=1")).get("body"))
59
+ sid = sell["items"][0]["seller_id"]
60
+ chk("seller detail", await c.get(f"/sellers/{sid}"))
61
+ chk("seller products", await c.get(f"/sellers/{sid}/products?size=2"))
62
+
63
+ # order id from analytics top-sellers join path: pull one from orders list
64
+ ords = _json.loads((await c.get("/orders?size=1")).get("body"))
65
+ oid = ords["items"][0]["order_id"]
66
+ chk("order detail", await c.get(f"/orders/{oid}"))
67
+ chk("order timeline", await c.get(f"/orders/{oid}/timeline"))
68
+
69
+ # 404 behavior
70
+ chk("product 404", await c.get("/products/does-not-exist"), expect_status=404)
71
+ chk("search missing q", await c.get("/search"), expect_status=400)
72
+
73
+ print("\n=== SMOKE RESULTS ===")
74
+ failed = 0
75
+ for name, ok, status in checks:
76
+ print(f" {'PASS' if ok else 'FAIL'} {name} (status {status})")
77
+ if not ok:
78
+ failed += 1
79
+ print(f"\n{failed} failed of {len(checks)}")
80
+ return 1 if failed else 0
81
+
82
+
83
+if __name__ == "__main__":
84
+ raise SystemExit(asyncio.run(main()))
0
85