Bỏ qua

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ềnLệnh chạyKế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úckhông có field lỗi ngoài dự kiến.
  • Case nào cần 1 id thậ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.fieldsmả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 extension unaccent. 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 /payments có 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 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, user resolve đú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ế.