GraphQL Verification Guide — bộ cú pháp tự kiểm tra toàn hệ thống
Mục đích: cho bạn copy-paste chạy trực tiếp (curl hoặc Playground) để tự verify từng case của lớp GraphQL đã implement (Phase 0-4), không cần đọc code. Mỗi case gồm: Mục đích → Điều kiện quyền → Lệnh chạy → Kết quả mong đợi.
Tài liệu tham chiếu đầy đủ field/args: GRAPHQL_API_GUIDE_VI.md · _EN.md
0. Chuẩn bị
0.1. Lấy token cho 3 role test (realm dev, seed sẵn trong config/keycloak/realm-export.json)
Token hết hạn sau ~20 phút — hết hạn thì chạy lại đúng lệnh tương ứng.
# Customer
CUSTOMER=$(curl -s -X POST "http://localhost:8080/realms/football-booking/protocol/openid-connect/token" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=password" -d "client_id=api-gateway" -d "client_secret=api-gateway-secret-key-12345" \
-d "username=customer1" -d "password=customer123" | python3 -c "import json,sys; print(json.load(sys.stdin)['access_token'])")
# Field Owner
OWNER=$(curl -s -X POST "http://localhost:8080/realms/football-booking/protocol/openid-connect/token" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=password" -d "client_id=api-gateway" -d "client_secret=api-gateway-secret-key-12345" \
-d "username=fieldowner1" -d "password=owner123" | python3 -c "import json,sys; print(json.load(sys.stdin)['access_token'])")
# Admin
ADMIN=$(curl -s -X POST "http://localhost:8080/realms/football-booking/protocol/openid-connect/token" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=password" -d "client_id=api-gateway" -d "client_secret=api-gateway-secret-key-12345" \
-d "username=admin" -d "password=admin123" | python3 -c "import json,sys; print(json.load(sys.stdin)['access_token'])")
0.2. Cách chạy — chọn 1 trong 2
Cách A — curl (dùng xuyên suốt tài liệu này):
curl -s -X POST http://localhost:3000/graphql \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $CUSTOMER" \
-d '{"query":"{ health }"}' | python3 -m json.tool
Bỏ dòng -H "Authorization..." cho các query Public.
Cách B — GraphQL Playground: mở http://localhost:3000/graphql trên trình duyệt → tab HTTP HEADERS ở góc dưới trái, dán {"Authorization": "Bearer <token>"} → dán query vào ô bên trái → bấm ▶.
0.3. Quy ước đọc kết quả trong tài liệu này
- ✅ Kết quả mong đợi — mô tả đúng những gì phải thấy. Giá trị cụ thể (id, số lượng, doanh thu...) sẽ khác trên máy bạn tùy dữ liệu đã seed — đừng so khớp tuyệt đối, chỉ cần đúng cấu trúc và không có field lỗi ngoài dự kiến.
- Case nào cần 1
idthật (booking/field/payment/user) → luôn có bước "lấy id trước" bằng 1 query list, dùng ngay id đó cho case tiếp theo.
1. Health — kiểm tra sống
Mục đích: xác nhận POST /graphql phản hồi, không cần auth.
Quyền: Public.
curl -s -X POST http://localhost:3000/graphql -H "Content-Type: application/json" \
-d '{"query":"{ health }"}' | python3 -m json.tool
✅ Kết quả mong đợi:
{ "data": { "health": "ok" } }
Không có field errors.
2. Field — sân bóng
2.1. fields(filter) — tìm/liệt kê sân (public)
Mục đích: xác nhận search public hoạt động, đồng thời lấy 1 id thật để dùng cho các case sau.
Quyền: Public.
curl -s -X POST http://localhost:3000/graphql -H "Content-Type: application/json" \
-d '{"query":"{ fields(filter: { limit: 5 }) { id name status hourlyRate } }"}' | python3 -m json.tool
✅ Kết quả mong đợi: data.fields là mảng (có thể rỗng nếu DB chưa có sân ACTIVE), không có errors.
{
"data": {
"fields": [
{
"id": "082bead4-...",
"name": "San Test GraphQL PoC",
"status": "ACTIVE",
"hourlyRate": 200000
}
]
}
}
→ Lưu lại 1 id từ đây, gán FIELD_ID="<id vừa lấy>", dùng cho 2.2–2.4.
2.2. field(id) — chi tiết 1 sân + toàn bộ field lồng
Mục đích: xác nhận resolve lười (chỉ gọi downstream khi field được yêu cầu) và nested field hoạt động đúng.
Quyền: Public cho owner/reviews/images/scheduleGrid/availability; cần token (Auth) cho bookingStats.
FIELD_ID="<dán id từ bước 2.1>"
curl -s -X POST http://localhost:3000/graphql -H "Content-Type: application/json" -H "Authorization: Bearer $CUSTOMER" \
-d '{"query":"{ field(id: \"'"$FIELD_ID"'\") { id name address hourlyRate status owner { name email } images { url isPrimary } reviews { rating comment } } }"}' | python3 -m json.tool
✅ Kết quả mong đợi:
{
"data": {
"field": {
"id": "082bead4-...",
"name": "San Test GraphQL PoC",
"address": "123 Test Street, Ho Chi Minh City",
"hourlyRate": 200000,
"status": "ACTIVE",
"owner": { "name": "Field Owner", "email": "owner@football-booking.com" },
"images": [],
"reviews": []
}
}
}
images/reviews rỗng là bình thường nếu chưa có ảnh/đánh giá — quan trọng là mảng, không phải null, và không có errors.
2.3. field.scheduleGrid / field.availability — lịch theo ngày
Mục đích: xác nhận field ngày-tháng lồng trong slots[]/availableSlots[] serialize đúng (đây là bug đã gặp và sửa ở Phase 3/5 — nếu tái phát sẽ thấy lỗi DateTime.serialize(...) returned null).
Quyền: Public.
curl -s -X POST http://localhost:3000/graphql -H "Content-Type: application/json" \
-d '{"query":"{ field(id: \"'"$FIELD_ID"'\") { scheduleGrid(date: \"2026-08-10\") { slotMinutes summary { totalSlots bookedSlots availableSlots } slots { startTime status } } availability(date: \"2026-08-11\") { totalSlots availableSlots { startTime endTime } } } }"}' | python3 -m json.tool
✅ Kết quả mong đợi: startTime/endTime là chuỗi ISO hợp lệ (vd "2026-08-10T01:00:00.000Z"), không có field errors nào nhắc tới DateTime.serialize.
2.4. field.bookingStats — thống kê (cần auth)
Mục đích: xác nhận field lồng yêu cầu auth riêng biệt với field(id) gốc (vốn public).
Quyền: Auth (không cần role cụ thể).
echo "--- không token: chỉ bookingStats lỗi, các field khác vẫn resolve ---"
curl -s -X POST http://localhost:3000/graphql -H "Content-Type: application/json" \
-d '{"query":"{ field(id: \"'"$FIELD_ID"'\") { name bookingStats(startDate: \"2026-01-01\", endDate: \"2026-12-31\") { totalRevenue } } }"}' | python3 -m json.tool
echo "--- có token: thành công ---"
curl -s -X POST http://localhost:3000/graphql -H "Content-Type: application/json" -H "Authorization: Bearer $CUSTOMER" \
-d '{"query":"{ field(id: \"'"$FIELD_ID"'\") { name bookingStats(startDate: \"2026-01-01\", endDate: \"2026-12-31\") { totalBookings totalRevenue cancellationRate } } }"}' | python3 -m json.tool
✅ Kết quả mong đợi: lần 1 → data.field.name có giá trị, data.field.bookingStats = null, kèm 1 lỗi UNAUTHENTICATED cho riêng path ["field","bookingStats"]. Lần 2 → không có errors, bookingStats có số liệu.
2.5. chatbotSearchFields — search riêng cho chatbot (public)
curl -s -X POST http://localhost:3000/graphql -H "Content-Type: application/json" \
-d '{"query":"{ chatbotSearchFields(filter: { name: \"Test\", limit: 5 }) { id name } }"}' | python3 -m json.tool
✅ Kết quả mong đợi: data.chatbotSearchFields là mảng, không errors.
Nếu gặp lỗi
function unaccent(character varying) does not exist→ Postgres của field-service chưa bật extensionunaccent. Chạy:docker exec -i postgres psql -U football_admin -d field_db -c "CREATE EXTENSION IF NOT EXISTS unaccent;"
2.6. pendingFields — danh sách sân chờ duyệt (ADMIN)
echo "--- customer: phải FORBIDDEN ---"
curl -s -X POST http://localhost:3000/graphql -H "Content-Type: application/json" -H "Authorization: Bearer $CUSTOMER" \
-d '{"query":"{ pendingFields { id } }"}' | python3 -c "import json,sys; print(json.load(sys.stdin)['errors'][0]['extensions']['code'])"
echo "--- admin: thành công ---"
curl -s -X POST http://localhost:3000/graphql -H "Content-Type: application/json" -H "Authorization: Bearer $ADMIN" \
-d '{"query":"{ pendingFields { id name status } }"}' | python3 -m json.tool
✅ Kết quả mong đợi: dòng 1 in ra FORBIDDEN; dòng 2 trả data.pendingFields là mảng (rỗng nếu không có sân PENDING_APPROVAL).
3. Booking
3.1. myBookings — booking của chính mình
Mục đích: lấy 1 id booking thật cho case 3.2, đồng thời xác nhận aggregation field/user/payment trong list.
curl -s -X POST http://localhost:3000/graphql -H "Content-Type: application/json" -H "Authorization: Bearer $CUSTOMER" \
-d '{"query":"{ myBookings { id status totalPrice field { name } user { name } payment { status } } }"}' | python3 -m json.tool
✅ Kết quả mong đợi: data.myBookings là mảng các booking của user customer1, mỗi phần tử có field.name/user.name (không null trừ khi user-service chưa có record tương ứng), payment có thể null nếu booking chưa thanh toán.
→ Lưu lại 1 id, gán BOOKING_ID="<id vừa lấy>".
3.2. booking(id) — chi tiết 1 booking
curl -s -X POST http://localhost:3000/graphql -H "Content-Type: application/json" -H "Authorization: Bearer $CUSTOMER" \
-d '{"query":"{ booking(id: \"'"$BOOKING_ID"'\") { id status totalPrice startTime endTime field { name address } user { name email } payment { status paymentMethod } } }"}' | python3 -m json.tool
✅ Kết quả mong đợi:
{
"data": {
"booking": {
"id": "d0217fd0-...",
"status": "EXPIRED",
"totalPrice": 400000,
"field": { "name": "San Test GraphQL PoC", "address": "..." },
"user": { "name": "Customer User", "email": "customer@football-booking.com" },
"payment": null
}
}
}
3.3. bookingsAdmin — toàn bộ booking (ADMIN)
echo "--- customer: FORBIDDEN ---"
curl -s -X POST http://localhost:3000/graphql -H "Content-Type: application/json" -H "Authorization: Bearer $CUSTOMER" \
-d '{"query":"{ bookingsAdmin { id } }"}' | python3 -c "import json,sys; print(json.load(sys.stdin)['errors'][0]['extensions']['code'])"
echo "--- admin: thành công ---"
curl -s -X POST http://localhost:3000/graphql -H "Content-Type: application/json" -H "Authorization: Bearer $ADMIN" \
-d '{"query":"{ bookingsAdmin(limit: 5) { id status } }"}' | python3 -m json.tool
✅ Kết quả mong đợi: dòng 1 → FORBIDDEN; dòng 2 → data.bookingsAdmin là mảng, tối đa 5 phần tử.
4. Admin Dashboard
Mục đích: xác nhận từng field lồng (pendingFieldApprovals/today/users) tự resolve và tự fail độc lập.
Quyền: ADMIN.
curl -s -X POST http://localhost:3000/graphql -H "Content-Type: application/json" -H "Authorization: Bearer $ADMIN" \
-d '{"query":"{ adminDashboard { pendingFieldApprovals today { bookings revenue cancellationRate } users { total byRole byStatus } } }"}' | python3 -m json.tool
✅ Kết quả mong đợi: cả 3 field đều có giá trị (không null) khi cả 3 downstream service đang chạy.
{
"data": {
"adminDashboard": {
"pendingFieldApprovals": 0,
"today": { "bookings": 0, "revenue": 0, "cancellationRate": 0 },
"users": { "total": 3, "byRole": {...}, "byStatus": {...} }
}
}
}
Test chịu lỗi từng phần (tùy chọn, cần dừng field-service để thấy rõ):
# Sau khi tắt field-service (Ctrl+C tiến trình đang serve nó), chạy lại query trên
✅ Kết quả mong đợi khi field-service down: pendingFieldApprovals = null, có 1 entry trong errors với path ["adminDashboard","pendingFieldApprovals"] và code SERVICE_UNAVAILABLE — nhưng today/users vẫn có giá trị vì chúng gọi service khác (đây chính là điểm khác biệt cốt lõi so với REST GET /admin/dashboard cũ, vốn fail toàn bộ theo kiểu Promise.allSettled thủ công).
5. Owner Dashboard
Mục đích: xác nhận waterfall 2 bước cũ (/bookings/owner/bookings + /bookings/owner/revenue) giờ chỉ còn 1 lần lấy fieldIds.
Quyền: FIELD_OWNER hoặc ADMIN.
echo "--- customer: FORBIDDEN ---"
curl -s -X POST http://localhost:3000/graphql -H "Content-Type: application/json" -H "Authorization: Bearer $CUSTOMER" \
-d '{"query":"{ ownerDashboard { fields { id } } }"}' | python3 -c "import json,sys; print(json.load(sys.stdin)['errors'][0]['extensions']['code'])"
echo "--- field owner: thành công, cả 3 field trong 1 request ---"
curl -s -X POST http://localhost:3000/graphql -H "Content-Type: application/json" -H "Authorization: Bearer $OWNER" \
-d '{"query":"{ ownerDashboard { fields { id name status } bookings(limit: 5) { id status } revenue(startDate: \"2026-01-01\", endDate: \"2026-12-31\") { totalRevenue totalBookings monthly { month revenue bookings } } } }"}' | python3 -m json.tool
✅ Kết quả mong đợi: dòng 1 → FORBIDDEN. Dòng 2 → cả 3 field fields/bookings/revenue có dữ liệu trong 1 response duy nhất, fields chỉ gồm sân của fieldowner1.
6. Payment
6.1. payments / payment(id) (ADMIN)
echo "--- customer: FORBIDDEN ---"
curl -s -X POST http://localhost:3000/graphql -H "Content-Type: application/json" -H "Authorization: Bearer $CUSTOMER" \
-d '{"query":"{ payments { id } }"}' | python3 -c "import json,sys; print(json.load(sys.stdin)['errors'][0]['extensions']['code'])"
echo "--- admin: thành công ---"
curl -s -X POST http://localhost:3000/graphql -H "Content-Type: application/json" -H "Authorization: Bearer $ADMIN" \
-d '{"query":"{ payments(limit: 5) { id status paymentMethod transactions { type status } } }"}' | python3 -m json.tool
✅ Kết quả mong đợi: dòng 1 → FORBIDDEN. Dòng 2 → data.payments là mảng (rỗng nếu chưa có giao dịch nào), mỗi phần tử có transactions là mảng lồng.
Lưu ý: REST
GET /paymentscó ghi 403-ADMIN trong Swagger nhưng thực tế không enforce (gap có sẵn) — GraphQL cố tình chặt hơn REST ở đây, xem §3.5 trong GRAPHQL_API_GUIDE.
6.2. payment(id) không tồn tại
curl -s -X POST http://localhost:3000/graphql -H "Content-Type: application/json" -H "Authorization: Bearer $ADMIN" \
-d '{"query":"{ payment(id: \"00000000-0000-0000-0000-000000000000\") { id } }"}' | python3 -m json.tool
✅ Kết quả mong đợi: data.payment = null, errors[0].extensions.code = "NOT_FOUND".
7. Owner Applications
echo "--- myOwnerApplications: bất kỳ user nào đã login ---"
curl -s -X POST http://localhost:3000/graphql -H "Content-Type: application/json" -H "Authorization: Bearer $CUSTOMER" \
-d '{"query":"{ myOwnerApplications { id status businessName } }"}' | python3 -m json.tool
echo "--- ownerApplications (admin list): customer FORBIDDEN ---"
curl -s -X POST http://localhost:3000/graphql -H "Content-Type: application/json" -H "Authorization: Bearer $CUSTOMER" \
-d '{"query":"{ ownerApplications { id } }"}' | python3 -c "import json,sys; print(json.load(sys.stdin)['errors'][0]['extensions']['code'])"
echo "--- ownerApplications: admin thành công, kèm user ---"
curl -s -X POST http://localhost:3000/graphql -H "Content-Type: application/json" -H "Authorization: Bearer $ADMIN" \
-d '{"query":"{ ownerApplications { id status businessName user { name email } } }"}' | python3 -m json.tool
✅ Kết quả mong đợi: 2 query đầu trả mảng (rỗng ok) / FORBIDDEN tương ứng; query 3 mỗi đơn có user resolve đúng người nộp (batch qua internal user id, không phải Keycloak id — xem GRAPHQL_API_GUIDE §5).
8. Admin User Directory
echo "--- customer: FORBIDDEN ---"
curl -s -X POST http://localhost:3000/graphql -H "Content-Type: application/json" -H "Authorization: Bearer $CUSTOMER" \
-d '{"query":"{ users { id } }"}' | python3 -c "import json,sys; print(json.load(sys.stdin)['errors'][0]['extensions']['code'])"
echo "--- admin: users(filter) ---"
curl -s -X POST http://localhost:3000/graphql -H "Content-Type: application/json" -H "Authorization: Bearer $ADMIN" \
-d '{"query":"{ users(filter: { limit: 5 }) { id name email role status } }"}' | python3 -m json.tool
→ Lưu 1 id từ kết quả trên, gán USER_ID="<id vừa lấy>".
echo "--- admin: user(id) ---"
curl -s -X POST http://localhost:3000/graphql -H "Content-Type: application/json" -H "Authorization: Bearer $ADMIN" \
-d '{"query":"{ user(id: \"'"$USER_ID"'\") { id name email role status } }"}' | python3 -m json.tool
echo "--- user(id) không tồn tại: NOT_FOUND ---"
curl -s -X POST http://localhost:3000/graphql -H "Content-Type: application/json" -H "Authorization: Bearer $ADMIN" \
-d '{"query":"{ user(id: \"00000000-0000-0000-0000-000000000000\") { id } }"}' | python3 -m json.tool
✅ Kết quả mong đợi: users trả mảng directory; user(id) với id thật trả đúng user đó; với id giả trả data.user: null kèm NOT_FOUND.
9. Locations (dữ liệu tham chiếu)
echo "--- provinces ---"
curl -s -X POST http://localhost:3000/graphql -H "Content-Type: application/json" \
-d '{"query":"{ provinces { code name nameEn } }"}' | python3 -c "import json,sys; d=json.load(sys.stdin); print('so tinh:', len(d['data']['provinces']))"
echo "--- districts (Hồ Chí Minh, code 22) ---"
curl -s -X POST http://localhost:3000/graphql -H "Content-Type: application/json" \
-d '{"query":"{ districts(provinceCode: \"22\") { code name nameEn } }"}' | python3 -m json.tool | head -15
✅ Kết quả mong đợi: provinces trả đúng 34 tỉnh/thành (sau sáp nhập 1/7/2025); districts("22") trả danh sách quận/huyện thuộc TP.HCM, không errors.
10. Các case lỗi có chủ đích (negative cases)
Mục đích của phần này: chứng minh error code đúng ngữ cảnh, không phải lỗi ngẫu nhiên.
10.1. UNAUTHENTICATED — không có token
curl -s -X POST http://localhost:3000/graphql -H "Content-Type: application/json" \
-d '{"query":"{ payments { id } }"}' | python3 -m json.tool
✅ errors[0].extensions.code = "UNAUTHENTICATED", errors[0].message = "Unauthorized".
10.2. FORBIDDEN — có token nhưng sai role
curl -s -X POST http://localhost:3000/graphql -H "Content-Type: application/json" -H "Authorization: Bearer $CUSTOMER" \
-d '{"query":"{ payments { id } }"}' | python3 -m json.tool
✅ errors[0].extensions.code = "FORBIDDEN" (khác với 10.1 dù cùng field — chứng tỏ auth check chạy trước role check, và phân biệt đúng 2 tình huống).
10.3. NOT_FOUND — id không tồn tại
curl -s -X POST http://localhost:3000/graphql -H "Content-Type: application/json" -H "Authorization: Bearer $CUSTOMER" \
-d '{"query":"{ booking(id: \"00000000-0000-0000-0000-000000000000\") { id } }"}' | python3 -m json.tool
✅ data.booking = null và errors[0].extensions.code = "NOT_FOUND" — khác field(id) non-existent chỉ vì message khác nhau, cùng cơ chế.
10.4. BAD_USER_INPUT — tham số vi phạm validation
curl -s -X POST http://localhost:3000/graphql -H "Content-Type: application/json" \
-d '{"query":"{ fields(filter: { minRating: 10 }) { id } }"}' | python3 -m json.tool
✅ Kết quả mong đợi (minRating tối đa cho phép là 5):
{
"errors": [
{
"message": ["minRating must not be greater than 5"],
"extensions": { "code": "BAD_USER_INPUT", "httpStatus": 400 }
}
],
"data": null
}
10.5. SERVICE_UNAVAILABLE — downstream không kết nối được
# Tắt field-service (Ctrl+C tiến trình serve field-service), rồi chạy:
curl -s -X POST http://localhost:3000/graphql -H "Content-Type: application/json" \
-d '{"query":"{ fields(filter: { limit: 1 }) { id } }"}' | python3 -m json.tool
✅ Kết quả mong đợi: errors[0].extensions.code = "SERVICE_UNAVAILABLE", message dạng "A downstream service is unavailable." — không phải stack trace ECONNREFUSED thô.
Nhớ bật lại field-service sau khi test xong.
11. Kiểm tra DataLoader batching (Phase 2)
Mục đích: chứng minh N booking khác field vẫn chỉ tốn 1 lần gọi /fields/batch xuống field-service, không phải N lần.
Bước 1 — gọi trực tiếp batch endpoint với 2 id khác nhau để xác nhận cơ chế còn hoạt động:
curl -s "http://localhost:3002/fields/batch?ids=<field_id_1>,<field_id_2>" | python3 -c "
import json,sys
data = json.load(sys.stdin)
print(f'Trả về {len(data)} field trong 1 request:')
for f in data: print(' -', f['id'], ':', f['name'])
"
✅ Kết quả mong đợi: in ra đúng 2 field, tức 1 HTTP request trả cả 2 — không phải 2 request riêng lẻ.
Bước 2 — quan sát gián tiếp qua myBookings: nếu bạn có ≥ 2 booking cho các field khác nhau, query dưới đây phải trả đủ field.name cho từng booking mà không lỗi timeout/chậm bất thường, dù có bao nhiêu booking đi nữa (vì tất cả gộp vào 1 lần gọi field-service):
curl -s -X POST http://localhost:3000/graphql -H "Content-Type: application/json" -H "Authorization: Bearer $CUSTOMER" \
-d '{"query":"{ myBookings { id field { id name } } }"}' | python3 -m json.tool
✅ Mọi field đều resolve đúng, không có field nào bị null do lỗi (trừ khi field-service thật sự down).
12. Checklist tổng hợp
Tick từng dòng khi đã tự chạy và khớp kết quả mong đợi:
- [ ] 1.
health→"ok" - [ ] 2.1
fields(filter)→ mảng - [ ] 2.2
field(id)+ owner/images/reviews - [ ] 2.3
scheduleGrid/availability→ ngày-tháng không lỗi serialize - [ ] 2.4
bookingStats→ lỗi riêng khi thiếu token, field khác vẫn resolve - [ ] 2.5
chatbotSearchFields→ mảng - [ ] 2.6
pendingFields→ FORBIDDEN/thành công đúng role - [ ] 3.1
myBookings→ mảng có field/user/payment - [ ] 3.2
booking(id)→ chi tiết đầy đủ - [ ] 3.3
bookingsAdmin→ FORBIDDEN/thành công đúng role - [ ] 4.
adminDashboard→ 3 field độc lập, chịu lỗi từng phần - [ ] 5.
ownerDashboard→ 1 request thay 2 - [ ] 6.1
payments→ FORBIDDEN/thành công đúng role - [ ] 6.2
payment(id)NOT_FOUND - [ ] 7.
myOwnerApplications/ownerApplications→ đúng quyền,userresolve đúng - [ ] 8.
users/user(id)→ đúng quyền, NOT_FOUND đúng - [ ] 9.
provinces(34)/districts - [ ] 10.1-10.5 — 5 mã lỗi đều đúng ngữ cảnh
- [ ] 11. Batching — 1 request cho nhiều id
Nếu tất cả tick được → toàn bộ Phase 0-5 của lớp GraphQL đã hoạt động đúng như thiết kế.