# Manual test flow — Dashboard Orders APIs (Feature 04)

Everything below is runnable against a local server (`php artisan serve` at `http://127.0.0.1:8000`)
and the Postman collection **Kapitano-Logistic-Mobile**, folder `Dashboard (Admin) — Orders`.

**Conventions used below**
- `{{host}}` → `http://127.0.0.1:8000`, `{{admin-token}}` → the token from the admin login.
- Envelope every response: `Model` (data), `Status` (true/false), `Message`, `MessageDebug`
  (validation/errors), `Total`/`Page`/`Records` (only meaningful on the paginated list).
- Validation failures are HTTP 422 with `Message = "Check the data"` and
  `MessageDebug.validation.<field>` naming the offending field.
- Every request must send `Accept: application/json` and, except login, `Authorization: Bearer {{admin-token}}`.

---

## 0. Preconditions (do once)

1. **Fresh known state** (dev DB only):
   ```
   php artisan migrate:fresh --seed
   ```
   If you do not want to wipe data, re-run the seeders instead — they are update-or-create:
   ```
   php artisan db:seed --class=PermissionSeeder
   php artisan db:seed --class=RoleSeeder
   php artisan db:seed --class=DriverSeeder
   php artisan db:seed --class=VehicleSeeder
   php artisan db:seed --class=OrderSeeder
   ```
2. Start the server and confirm the routes exist: `php artisan route:list --path=orders`
   (expected: `index`, `store`, `show`, `captains`, `assign`).
3. **Login (Dashboard → Auth → Login)**:
   ```json
   { "email": "super-admin@kapitano-logiistic.com", "password": "123456", "device_name": "postman" }
   ```
   → 200, copy `Model.token` into the `admin-token` collection variable.

### The seeded captain state (what suggestions must produce)

| Captain (phone) | Status / active | Online + located | Carries | Suggestable? |
|---|---|---|---|---|
| محمد الجاعور `+963932174371` | approved / yes | yes (24.700, 46.650) | ORD-100001 **assigned** | **No — busy** |
| Nasser Al Otaibi `+966500000002` | approved / yes | yes (24.712, 46.668) | ORD-100002 **on_the_way** | **No — busy** |
| Abdulaziz Al Ajlan `+966500000008` | approved / yes | yes (24.724, 46.686) | none | **Yes — the only one** |
| Sami `+966500000003` | pending | no | — | No — not approved |
| Majed `+966500000004` | rejected | no | — | No |
| Bassam `+966500000006` | documents_required | no | — | No |
| Turki `+966500000005` / Faisal `+966500000007` | approved/inactive, suspended/inactive | — | — | No |

Seeded orders: ORD-100001 assigned, ORD-100002 on_the_way, ORD-100003 delivered,
ORD-100004 **pending and unassigned** (use it as the assignable one).

---

## 1. GET `{{host}}/api/dashboard/orders` — list

Base: `GET {{host}}/api/dashboard/orders?page=1&rows=15`

| # | Case | Request | Expected |
|---|------|---------|----------|
| 1 | No filters | `?page=1&rows=15` | 200, `Status=true`, `Model` is an array, `Total` = total orders, `Page=1`, `Records` = last page #; list rows carry `order_number`, `status`, `status_label`, `driver` (when assigned), `items`, `created_at` |
| 2 | Filter by status | `?status=pending` | 200, every row `status=pending` |
| 3 | Each status value | `?status=assigned`, `=picked_up`, `=on_the_way`, `=delivered`, `=delivery_failed`, `=cancelled` | 200; cancelled/delivery_failed return 0 rows unless data exists |
| 4 | Search by order number | `?search=ORD-10000` | 200, rows with matching order numbers |
| 5 | Search by customer name | `?search=Khalid` | 200, ORD-100002 (customer Khalid) |
| 6 | Search by customer phone | `?search=+966500000101` | 200, ORD-100002 |
| 7 | Pagination | `?rows=2&page=1` then `?rows=2&page=2` | 200, `Model` has ≤2 rows; next page shows different records |
| 8 | Combined | `?status=pending&search=ORD-100004` | 200, exactly ORD-100004 |
| 9 | Invalid status enum | `?status=xxx` | 422, `MessageDebug.validation.status` |
| 10 | Invalid pagination | `?rows=abc` or `?rows=0` or `?page=999999` | 422 (rows/page validation) or 200 empty page |
| 11 | No token | drop the Authorization header | 401, `Status=false` |

---

## 2. GET `{{host}}/api/dashboard/orders/{{order-uuid}}` — detail

Set `order-uuid` to **ORD-100004**'s uuid first (easy source: open the order and copy `Model.uuid`).
To get the uuid of a seeded order: `GET {{host}}/api/dashboard/orders?search=ORD-100004` then copy `Model[0].uuid`.

| # | Case | Request | Expected |
|---|------|---------|----------|
| 12 | Pending, unassigned order | `GET …/orders/{{order-uuid}}` | 200, `Model.status=pending`, `Model.driver` = null block, `Model.items`, `Model.history` only shows the `pending` step with `actor` null |
| 13 | Assigned order | set `order-uuid` to ORD-100001's uuid | 200, `Model.status=assigned`, `Model.driver.uuid` = the captain (محمد الجاعور), `history` shows pending → assigned, the assigned step `actor` = the admin name |
| 14 | On-the-way order | ORD-100002 | 200, `Model.status=on_the_way`, history 3 steps |
| 15 | Unknown uuid | `…/orders/01xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx9` (random, valid uuid format) | 404 |
| 16 | Malformed uuid | `…/orders/not-a-uuid` | 404 (route constraint) |
| 17 | No token | remove Authorization | 401 |

> uuid note: order uuids are **uuid v7** (e.g. `01a08xxx-…`); the `order-uuid` variable must hold one for the routes to match.

---

## 3. POST `{{host}}/api/dashboard/orders` — create (fake order)

Body: raw JSON.
```json
{
  "customer_name": "Nasser Al Otaibi",
  "customer_phone": "+966500009999",
  "pickup_address": "Warehouse 3, Industrial City, Riyadh",
  "pickup_lat": 24.6840944,
  "pickup_lng": 46.6014408,
  "dropoff_address": "King Fahd Road, Riyadh",
  "dropoff_lat": 24.7135517,
  "dropoff_lng": 46.6752957,
  "fee": 35.00,
  "currency": "SAR",
  "note": "Call on arrival",
  "items": [
    { "name": "Document box", "quantity": 2, "unit_price": 15.00 },
    { "name": "Small parcel", "quantity": 1, "unit_price": 30.00, "note": "fragile" }
  ]
}
```

| # | Case | Request | Expected |
|---|------|---------|----------|
| 18 | Happy path with items | body above | 201, `Message="The order has been created."`, `Model.order_number` like `ORD-100005`, `Model.status=pending`, `Model.items` = 2 rows with quantity/unit_price, `Model.created_at` set |
| 19 | Minimal (no items, no coords) | `{ "customer_name":"A", "pickup_address":"X", "dropoff_address":"Y" }` | 201, pending, `Model.items` = null, coords null |
| 20 | **Missing required fields** | empty `{}` | 422, `MessageDebug.validation` lists `customer_name`, `pickup_address`, `dropoff_address` |
| 21 | **Broken coordinate pair** | send `pickup_lat` only (no `pickup_lng`) | 422, `MessageDebug.validation.pickup_lng` ("required_with") — same for `dropoff_lng` |
| 22 | Latitude out of range | `pickup_lat: 95` | 422 `pickup_lat.between` |
| 23 | Longitude out of range | `dropoff_lng: 181` | 422 `dropoff_lng.between` |
| 24 | **Item row missing name** | `items: [{ "quantity": 1 }]` | 422 `items.0.name` |
| 25 | **Item with zero quantity** | `items: [{ "name":"x", "quantity": 0 }]` | 422 `items.0.quantity` (`min:1`) |
| 26 | Fee negative | `fee: -5` | 422 `fee` (`min:0`) |
| 27 | Too many items | `items`: 51 rows | 422 `items` (`max:50`) |
| 28 | No token | — | 401 |

> The created order **stays pending** — creating an order never assigns a captain by itself.

---

## 4. GET `{{host}}/api/dashboard/orders/{{order-uuid}}/captains` — suggestions

Use the order you just created in step 18/19 (it has pickup coords → has suggestions).

| # | Case | Request | Expected |
|---|------|---------|----------|
| 29 | Happy path (fresh DB) | `GET …/orders/{created}/captains` | 200, `Model` = **1 suggestion**: Abdulaziz Al Ajlan, `Model[0].distance_km` = number (≈ 9.6 for the Warehouse pickup; order of a few km from his 24.724/46.686), `Model[0].captain.uuid` = Abdulaziz's ulid |
| 30 | Ranking sanity | after assigning the order to Abdulaziz, create a *new* order near Nasser's spot and call captains | 200, **empty** `Model` — every approved+active captain is now busy |
| 31 | **No pickup coordinates** | take the order from case 19 (minimal, no coords) | 422, `Status=false`, `Message="The order needs pickup coordinates before captains can be suggested."` |
| 32 | Unknown uuid | random uuid | 404 |
| 33 | No token | — | 401 |

**What you will NEVER see in this list** (exclusion rule): pending/rejected/documents-required/suspended
captains, inactive captains, offline captains, captains with no reported location, and captains already
carrying an order in `assigned`, `picked_up` or `on_the_way`.

> Optional live proof of the "busy" rule with the seeded data: check the list for ORD-100001's pickup —
> محمد الجاعور sits ~2 km away and is online, yet he does **not** appear because he carries ORD-100001.

---

## 5. PATCH `{{host}}/api/dashboard/orders/{{order-uuid}}/assign` — assign

Body: `{ "driver_uuid": "{{captain-uuid}}" }` (`captain-uuid` = Abdulaziz's ulid from the suggestions list).

| # | Case | Request | Expected |
|---|------|---------|----------|
| 34 | Happy path | pending ORD-100004 → assign Abdulaziz | 200, `Message="The order has been assigned to a captain."`, `Model.status=assigned`, `Model.driver.uuid` = Abdulaziz, `history` has 2 steps, the `assigned` step `actor` = your admin name |
| 35 | State is real | open the order's detail (case 13 pattern) | 200, status `assigned`, driver block filled, `order_status_history` row exists with actor |
| 36 | **Re-assign same order** | replay case 34 with the now-busy Abdulaziz | 422, `Status=false`, `Message="The captain is not available to take this order right now."` (the availability guard, prioritized before the transition check) |
| 37 | **Assign to a busy captain** | new pending order, `driver_uuid` = محمد الجاعور's uuid (carries ORD-100001) | 422, same `captain_unavailable` message |
| 38 | Assign to a non-approved captain | `driver_uuid` = Sami (pending) or Majed (rejected) | 422 `captain_unavailable` |
| 39 | Assign to an offline/unlocated captain | any captain whose availability is offline or who has no location row (e.g. a freshly registered one) | 422 `captain_unavailable` |
| 40 | Unknown driver | `driver_uuid` = random valid ulid | 404 |
| 41 | Malformed driver | `driver_uuid: "abc"` | 422 `driver_uuid` (`ulid`) |
| 42 | Missing driver | body `{}` | 422 `driver_uuid` (`required`) |
| 43 | Assign a **delivered/on-the-way** order | ORD-100002 with a free captain (if you do this while Abdulaziz is still free, before case 30) | 422 — non-pending order: either `captain_unavailable` (if the chosen captain is busy) or `An order cannot move from on_the_way to assigned.` (if you point a second, free captain at it) |
| 44 | No token | — | 401 |

**Invalid-transition note:** the availability guard runs before the state machine, so the 422 you see for
a non-pending order is usually `captain_unavailable`. To observe `invalid_transition`
(`An order cannot move from … to assigned.`) you need two free captains: assign order A to captain 1,
then assign order B to captain 2 — once created, captain 2 rejects it. That path is covered by feature
tests; manually it needs a second free captain seeded.

### Verify the notification (optional)
After case 34, while the order is `assigned`:
- If the captain has a registered device token (existing captain `POST …/device-token` endpoint), the
  `OrderAssigned` event queues `NotifyCaptainOfAssignment` → `NewOrderAssignedNotification`
  (`type=order_assigned`, carries `order_uuid`). Check the log queue (`storage/logs/laravel.log`) for the
  FCM send attempt.
- With no device token, `via()` returns an empty channel list and **no push is attempted** — expected.

---

## 6. Permissions (the doors)

| # | Token | Action | Expected |
|---|-------|--------|----------|
| 45 | super-admin | every endpoint | 200 (bypasses everything) |
| 46 | operations-manager admin | index, show, captains, store, assign | 200 |
| 47 | support admin | index, show, captains | 200 |
| 48 | support admin | store or assign | **403** `Status=false` (support has only `orders.view`) |
| 49 | captain (driver) token | any dashboard order endpoint | 401 (dashboard guard is admin-only) |
| 50 | invalid/expired token | any dashboard order endpoint | 401 |

> Creating a support admin to try 46–48: seed the role (RoleSeeder), then create an admin with
> `php artisan tinker` and attach the `support` role (`$admin->assignRole('support')`),
> then log in with that account.

---

## 7. Full-flow script (happy path, end to end)

1. Login admin → store token in `admin-token`.
2. **Store order** (case 18) → **201**; write `Model.uuid` into `order-uuid`. Order is `pending`.
3. **Suggestions** → 200, exactly one suggestion, only Abdulaziz; read and record `distance_km`.
4. Write Abdulaziz's `Model[0].captain.uuid` into `captain-uuid`.
5. **Assign** → 200, `status=assigned`.
6. **Show** → 200: `status=assigned`, `driver.uuid` matches, `history` = pending → assigned (actor = admin).
7. **List** `?status=assigned&search={order_number}` → 200, your order is on the returned page.
8. **Suggestion list again** for that order → 200, empty array — the only free captain (Abdulaziz) is now busy carrying it.
9. **Re-assign** (same order + same captain) → 422 `captain_unavailable`.
10. Cleanup: delete the throwaway order(s) created, or `php artisan migrate:fresh --seed` afterwards.