# Testing Documentation: Sub Agenda & Service API HANA on AHO (HOPES)

**Feature:** Dual-Service Sub Agenda + Sinkronisasi HOPES API  
**Branch:** `HRIS-202-aho`  
**Tanggal Dibuat:** 2026-04-15  
**Versi PHP/Laravel:** PHP 7.4 / Laravel 7  
**Status Automated Tests:** 24 tests, 56 assertions — ALL PASSED

---

## Daftar Isi

1. [Gambaran Arsitektur](#1-gambaran-arsitektur)
2. [Inventaris Komponen](#2-inventaris-komponen)
3. [Konfigurasi & Environment](#3-konfigurasi--environment)
4. [Testing: Sub Agenda Service Layer](#4-testing-sub-agenda-service-layer)
   - 4.1 SubAgendaService (Non-AHO)
   - 4.2 AHOSubAgendaService (AHO + HOPES Fields)
   - 4.3 Service Resolution Logic
5. [Testing: Check-Out & Business Rules HOPES](#5-testing-check-out--business-rules-hopes)
6. [Testing: HOPES API Integration](#6-testing-hopes-api-integration)
   - 6.1 SyncToHopesHdlr Listener
   - 6.2 PostToHopesJob
   - 6.3 SHA1 Signature Generation
7. [Testing: Endpoint Tambahan HOPES](#7-testing-endpoint-tambahan-hopes)
   - 7.1 `hopesSelectCust`
   - 7.2 `hopesResync`
8. [Testing: Authorization & Policy](#8-testing-authorization--policy)
9. [Automated Test Results (PHPUnit)](#9-automated-test-results-phpunit)
10. [Manual UAT Scenarios](#10-manual-uat-scenarios)
11. [Coverage Matrix](#11-coverage-matrix)
12. [Cara Menjalankan Tests](#12-cara-menjalankan-tests)

---

## 1. Gambaran Arsitektur

```
FORM SUB AGENDA
      │
      ▼
EmployeeAgendaController::storeSubAgenda() / updateSubAgenda()
      │
      ├── id_position ∈ config('hopes.positions') ? ──→ AHOSubAgendaService
      │                                                     ├── Validasi: title + ppk + cix_no + main_cd + sub_cd + prom_dt
      │                                                     ├── Simpan ke d_emp_sub_agendas (field HOPES terisi)
      │                                                     └── update(): deteksi perubahan field kritikal → re-sync
      │
      └── Posisi lain ──→ SubAgendaService
                            ├── Validasi: title saja
                            └── Simpan ke d_emp_sub_agendas (field HOPES = null)


CHECK-OUT SUB AGENDA
      │
      ▼
EmployeeAgendaController::checkOut()
      ├── Validasi: description + lat + lng + img (wajib jika ada ppk)
      ├── Business rule: ppk ada → sub_cd wajib, sub_cd=100 → prom_dt wajib
      ├── Simpan time_out, file_out
      ├── (jika ppk+cix_no ada) Update hopes_sync_status = 'pending'
      └── event(OnSubAgendaCompleted)
                │
                ▼
          SyncToHopesHdlr::handle()
                ├── Guard: ppk & cix_no harus ada → skip jika tidak
                ├── Guard: main_cd & sub_cd harus ada → update status 'failed' jika tidak
                ├── Guard: agenda & employee harus ada → skip jika tidak
                ├── Build payload (ppk, cix_no, regist_dt, regist_tm, op_no, op_nm, ...)
                ├── Generate signature: SHA1(ppk + regist_dt + regist_tm + op_no + key)
                └── PostToHopesJob::dispatch(subAgenda, payload)
                          │
                          ▼
                    PostToHopesJob::handle()
                          ├── POST {HOPES_BASE_URL}/insertAho
                          ├── response_code = "00" → update 'success'
                          ├── response_code ≠ "00" → update 'failed'
                          ├── RequestException → re-throw (trigger retry)
                          └── Attempt ≥ tries → update 'failed' + dispatch OnLoggingActivity
```

---

## 2. Inventaris Komponen

| File | Tipe | Tanggung Jawab |
|------|------|----------------|
| `app/Services/SubAgenda/SubAgendaServiceInterface.php` | Interface | Kontrak `store()` dan `update()` |
| `app/Services/SubAgenda/SubAgendaService.php` | Service | Logika non-AHO (tanpa HOPES) |
| `app/Services/SubAgenda/AHOSubAgendaService.php` | Service | Logika AHO: validasi + simpan field HOPES, deteksi re-sync |
| `app/Http/Controllers/EmployeeAgendaController.php` | Controller | Routing request ke service yang tepat, check-in/out, resync, selectCust |
| `app/Events/OnSubAgendaCompleted.php` | Event | Payload sub-agenda yang sudah check-out |
| `app/Listeners/SyncToHopesHdlr.php` | Listener | Guard clause + build payload + dispatch Job |
| `app/Jobs/PostToHopesJob.php` | Job | HTTP POST ke HOPES API, handle response & retry |
| `app/Policies/DEmpSubAgendaPolicy.php` | Policy | Otorisasi create/update/delete/resync sub-agenda |
| `app/Models/DEmpSubAgenda.php` | Model | Kolom HOPES: `ppk`, `cix_no`, `main_cd`, `sub_cd`, `prom_dt`, `hopes_sync_status`, `hopes_response`, `hopes_synced_at` |
| `config/hopes.php` | Config | `base_url`, `encryption_key`, `positions`, `timeout`, `retry_times`, `retry_delay` |
| `tests/Unit/HopesSignatureTest.php` | Unit Test | 7 tests untuk formula SHA1 |
| `tests/Unit/Listeners/SyncToHopesHdlrTest.php` | Unit Test | 7 tests untuk guard clause listener |
| `tests/Feature/HopesIntegrationTest.php` | Feature Test | 10 tests end-to-end dengan Guzzle Mock |

---

## 3. Konfigurasi & Environment

### 3.1 `config/hopes.php`

| Key | Env Var | Default (UAT) | Production |
|-----|---------|---------------|------------|
| `base_url` | `HOPES_API_URL` | `https://cvstrnUAT.shf.co.id` | `https://cvstrn.shf.co.id` |
| `encryption_key` | `HOPES_ENCRYPTION_KEY` | `ABC` | `hanaOnAhoApi` |
| `screen_id` | `HOPES_SCREEN_ID` | `OPIS` | `OPIS` |
| `timeout` | `HOPES_API_TIMEOUT` | `30` (detik) | `30` (detik) |
| `retry_times` | `HOPES_RETRY_TIMES` | `3` | `3` |
| `retry_delay` | `HOPES_RETRY_DELAY` | `5` (detik) | `5` (detik) |
| `positions` | `HOPES_POSITIONS` | `45` | `45` (atau lebih) |

### 3.2 Menentukan Posisi AHO

```php
// config/hopes.php:30
'positions' => array_map('intval', explode(',', env('HOPES_POSITIONS', '45'))),
```

Untuk menambah posisi AHO baru, **cukup ubah `.env`** tanpa deploy ulang:
```
HOPES_POSITIONS=45,46
```

### 3.3 Queue

```bash
# Menjalankan queue worker
php artisan queue:work --tries=3 --delay=5

# Monitor queue
php artisan queue:monitor
```

### 3.4 Tabel Database yang Terlibat

| Tabel | Kolom HOPES |
|-------|-------------|
| `d_emp_sub_agendas` | `ppk`, `cix_no`, `cust_nm`, `main_cd`, `sub_cd`, `prom_dt`, `hopes_sync_status` (`null`/`pending`/`success`/`failed`), `hopes_response` (JSON), `hopes_synced_at` |
| `jobs` | Queue jobs yang menunggu diproses |
| `failed_jobs` | Job yang gagal setelah semua retry habis |
| `activity_log` | Log sinkronisasi dengan `log_name = 'hopes_sync'` |

---

## 4. Testing: Sub Agenda Service Layer

### 4.1 SubAgendaService (Non-AHO)

**File:** `app/Services/SubAgenda/SubAgendaService.php`  
**Tujuan:** Posisi selain AHO — hanya `title` yang divalidasi, field HOPES tidak tersentuh.

#### Skenario `store()`

| ID | Skenario | Input | Expected | Validasi DB |
|----|----------|-------|----------|-------------|
| SVC-01 | Store berhasil dengan title valid | `title = "Kunjungan Cabang"` | Record baru terbuat. `ppk`, `cix_no`, `main_cd`, `sub_cd`, `prom_dt` = **null** | `d_emp_sub_agendas` hanya berisi `id_emp_agenda` + `title` |
| SVC-02 | Store gagal tanpa title | `title = null` | ValidationException: `title field is required` | Tidak ada record baru |
| SVC-03 | Field HOPES di request diabaikan | `title = "X"`, `ppk = "ABC123"` | Record terbuat, `ppk` tetap **null** (tidak disimpan) | Kolom `ppk` di DB = null |

#### Skenario `update()`

| ID | Skenario | Input | Expected | Validasi DB |
|----|----------|-------|----------|-------------|
| SVC-04 | Update title saja (belum check-out) | `title = "Baru"`, `time_out = null` | Title terupdate, field HOPES tidak berubah | `title` berubah, `ppk` tetap null |
| SVC-05 | Update saat check-out — description wajib | `time_out = "2026-04-15 10:00"`, `description = null` | ValidationException: `description field is required` | Record tidak berubah |
| SVC-06 | Update saat check-out — description terisi | `time_out = "2026-04-15"`, `description = "Catatan"` | Update berhasil | `description` tersimpan |
| SVC-07 | Field HOPES di request diabaikan saat update | `ppk = "BARU"` (ada di request) | `ppk` di DB **tidak berubah** | Kolom `ppk` tetap nilai semula |

---

### 4.2 AHOSubAgendaService (AHO + HOPES Fields)

**File:** `app/Services/SubAgenda/AHOSubAgendaService.php`  
**Tujuan:** Posisi AHO — validasi + simpan field HOPES. `update()` mendeteksi perubahan field kritikal untuk re-sync otomatis.

#### Skenario `store()`

| ID | Skenario | Input | Expected |
|----|----------|-------|----------|
| AHO-01 | Store berhasil dengan semua field HOPES | `title`, `ppk`, `cix_no`, `main_cd=10`, `sub_cd=101` | Record tersimpan dengan semua field HOPES |
| AHO-02 | Store gagal — ppk kosong | `ppk = null` | ValidationException: `ppk field is required` |
| AHO-03 | Store gagal — cix_no kosong | `cix_no = null` | ValidationException: `cix_no field is required` |
| AHO-04 | Store gagal — main_cd kosong | `main_cd = null` | ValidationException: `The Status Kunjungan field is required.` (custom message) |
| AHO-05 | Store gagal — sub_cd kosong | `sub_cd = null` | ValidationException: `The Sub Status field is required.` (custom message) |
| AHO-06 | prom_dt wajib jika sub_cd = 100 | `sub_cd = "100"`, `prom_dt = null` | ValidationException: `prom_dt field is required when sub cd is 100` |
| AHO-07 | prom_dt opsional jika sub_cd ≠ 100 | `sub_cd = "101"`, `prom_dt = null` | Store berhasil, `prom_dt` = null |
| AHO-08 | prom_dt harus format tanggal valid | `prom_dt = "bukan-tanggal"` | ValidationException: `prom_dt must be a valid date` |
| AHO-09 | cust_nm opsional | `cust_nm = null` | Store berhasil |
| AHO-10 | Default main_cd tidak dipakai di store | `main_cd = "10"` (explicit) | Tersimpan `main_cd = "10"` |

#### Skenario `update()` — Validasi

| ID | Skenario | Input | Expected |
|----|----------|-------|----------|
| AHO-11 | Update berhasil — semua field terisi | Semua field valid | Record terupdate |
| AHO-12 | Update saat check-out — description wajib | `time_out ≠ null`, `description = null` | ValidationException |
| AHO-13 | PPK dikosongkan → reset status HOPES | `ppk = ""` | `hopes_sync_status = null`, `hopes_response = null`, `hopes_synced_at = null` |

#### Skenario `update()` — Re-Sync Detection (GAP-1)

Field kritikal yang di-snapshot: `ppk`, `cix_no`, `main_cd`, `sub_cd`, `prom_dt`, `description`

| ID | Kondisi Awal | Perubahan | Expected |
|----|-------------|-----------|----------|
| AHO-14 | `hopes_sync_status = 'success'`, sudah check-out | Ubah `ppk` ke nilai baru | Re-sync terpicu: `hopes_sync_status = 'pending'`, `OnSubAgendaCompleted` ter-fire |
| AHO-15 | `hopes_sync_status = 'success'`, sudah check-out | Ubah `sub_cd` | Re-sync terpicu |
| AHO-16 | `hopes_sync_status = 'success'`, sudah check-out | Ubah `description` | Re-sync terpicu (description = `conts` di payload HOPES) |
| AHO-17 | `hopes_sync_status = 'success'`, sudah check-out | Tidak ada field kritikal yang berubah | **Tidak** ada re-sync |
| AHO-18 | `hopes_sync_status = 'failed'`, sudah check-out | Semua field sama | Re-sync terpicu (status bukan `success`) |
| AHO-19 | `hopes_sync_status = 'pending'` | Field kritikal berubah | **Tidak** ada re-sync (hindari job duplikat) |
| AHO-20 | Belum check-out (`time_out = null`) | Field kritikal berubah | **Tidak** ada re-sync (waktu check-out belum ada) |
| AHO-21 | PPK dikosongkan | Apapun | **Tidak** ada re-sync (ppk kosong = reset) |

---

### 4.3 Service Resolution Logic

**Lokasi:** `EmployeeAgendaController::storeSubAgenda()` (baris 278) dan `updateSubAgenda()` (baris 649)

```php
$service = in_array($idPosition, config('hopes.positions', []))
    ? new AHOSubAgendaService()
    : new SubAgendaService();
```

| ID | Skenario | `id_position` | `HOPES_POSITIONS` (env) | Service yang Dipakai |
|----|----------|--------------|------------------------|----------------------|
| RES-01 | Posisi AHO default | `45` | `45` | `AHOSubAgendaService` |
| RES-02 | Posisi AHO tambahan | `46` | `45,46` | `AHOSubAgendaService` |
| RES-03 | Posisi non-AHO | `44` (BM) | `45` | `SubAgendaService` |
| RES-04 | Posisi non-AHO | `94` (AM) | `45` | `SubAgendaService` |
| RES-05 | Tambah posisi tanpa deploy | `46` | `45,46` (ubah `.env`) | `AHOSubAgendaService` |

---

## 5. Testing: Check-Out & Business Rules HOPES

**Lokasi:** `EmployeeAgendaController::checkOut()` (baris 331)

### 5.1 Validasi Request

| ID | Skenario | Kondisi | Expected |
|----|----------|---------|----------|
| CO-01 | Check-out berhasil (non-AHO) | `description`, `lat`, `lng` terisi. `ppk = null`. | `time_out` tersimpan, tidak ada HOPES sync |
| CO-02 | Check-out berhasil (AHO) | Semua field terisi termasuk `img` (wajib karena `ppk ≠ null`). | `time_out` + `file_out` tersimpan, `hopes_sync_status = 'pending'` |
| CO-03 | Check-out gagal — description kosong | `description = null` | ValidationException: `description is required` |
| CO-04 | Check-out gagal — foto tidak ada (AHO) | `ppk ≠ null`, `img = null` | ValidationException: `img is required` |
| CO-05 | Check-out berhasil — foto opsional (non-AHO) | `ppk = null`, `img = null` | Check-out berhasil tanpa foto |

### 5.2 Business Rule Guard (hanya jika `ppk ≠ null`)

| ID | Skenario | Kondisi DB | Expected |
|----|----------|-----------|----------|
| CO-06 | sub_cd belum diisi | `sub_cd = null` di DB | HTTP 422: `"Sub kode aktivitas belum diisi..."` |
| CO-07 | sub_cd = 100 tapi prom_dt kosong | `sub_cd = "100"`, `prom_dt = null` | HTTP 422: `"Tanggal janji bayar wajib diisi..."` |
| CO-08 | sub_cd = 100, prom_dt terisi | `sub_cd = "100"`, `prom_dt = "2026-05-01"` | Check-out berhasil |
| CO-09 | sub_cd = 101 (bukan Janji Bayar) | `sub_cd = "101"`, `prom_dt = null` | Check-out berhasil |

### 5.3 Inisiasi HOPES Sync setelah Check-Out

| ID | Skenario | Kondisi | Expected |
|----|----------|---------|----------|
| CO-10 | HOPES sync dipicu | `ppk ≠ null` dan `cix_no ≠ null` | `hopes_sync_status = 'pending'` di DB, `OnSubAgendaCompleted` ter-fire |
| CO-11 | HOPES sync tidak dipicu | `ppk = null` | `hopes_sync_status` tetap `null`, tidak ada event |
| CO-12 | HOPES sync tidak dipicu | `cix_no = null` | `hopes_sync_status` tetap `null` |

---

## 6. Testing: HOPES API Integration

### 6.1 SyncToHopesHdlr Listener

**File:** `app/Listeners/SyncToHopesHdlr.php`  
**Automated Test:** `tests/Unit/Listeners/SyncToHopesHdlrTest.php`

#### Guard Clause — Tidak Dispatch Job

| ID | Skenario | Input | Expected | Test |
|----|----------|-------|----------|------|
| LIS-01 | `ppk = null` | Sub-agenda tanpa PPK | Return awal, `Queue::assertNotPushed` | `testSkipsSyncWhenPpkIsNull` ✅ |
| LIS-02 | `cix_no = null` | Sub-agenda tanpa CIX_NO | Return awal, tidak ada dispatch | `testSkipsSyncWhenCixNoIsNull` ✅ |
| LIS-03 | `ppk = ""` (empty string) | Empty string dianggap kosong | Return awal | `testSkipsSyncWhenPpkIsEmptyString` ✅ |
| LIS-04 | Kedua field kosong | `ppk = ""`, `cix_no = ""` | Return awal di guard pertama | `testSkipsSyncWhenBothFieldsAreEmpty` ✅ |
| LIS-05 | Agenda tidak ditemukan | `id_emp_agenda` tidak valid | Return awal | `testSkipsSyncWhenAgendaNotFound` ✅ |
| LIS-06 | Employee null | Agenda ada, employee null | Return awal | `testSkipsSyncWhenEmployeeIsNull` ✅ |

#### Guard Clause — `main_cd` / `sub_cd` Kosong (update status 'failed')

| ID | Skenario | Kondisi | Expected |
|----|----------|---------|----------|
| LIS-07 | `main_cd = null` | PPK + CIX_NO ada, main_cd tidak | Log warning, `hopes_sync_status = 'failed'`, tidak ada dispatch |
| LIS-08 | `sub_cd = null` | PPK + CIX_NO ada, sub_cd tidak | Log warning, `hopes_sync_status = 'failed'`, tidak ada dispatch |
| LIS-09 | Keduanya null | `main_cd = null`, `sub_cd = null` | Log warning, status `failed` |

#### Happy Path — Job Dispatch

| ID | Skenario | Kondisi | Expected | Test |
|----|----------|---------|----------|------|
| LIS-10 | Semua field HOPES terisi | `ppk`, `cix_no`, `main_cd`, `sub_cd` ada, agenda + employee valid | `PostToHopesJob` terdispatch dengan payload lengkap | `testDispatchesJobWhenAllHopesFieldsPresent` ✅ |

#### Payload yang Dibangun Listener

| Field | Sumber | Catatan |
|-------|--------|---------|
| `ppk` | `subAgenda->ppk` | |
| `cix_no` | `subAgenda->cix_no` | |
| `regist_dt` | `subAgenda->time_out` (format `Y-m-d`) | Fallback `Carbon::now()` jika time_out null |
| `regist_tm` | `subAgenda->time_out` (format `H:i:s`) | Fallback `Carbon::now()` |
| `op_no` | `employee->nik` | |
| `op_nm` | `employee->fullname` | |
| `main_cd` | `subAgenda->main_cd` | |
| `sub_cd` | `subAgenda->sub_cd` | |
| `conts` | `subAgenda->description ?? subAgenda->title` | |
| `promDt` | `subAgenda->prom_dt` (format `Y-m-d`) | Empty string `""` jika null |
| `file_name` | URL foto check-out (`file_out`) | Empty string `""` jika null |
| `signature` | `SHA1(ppk + regist_dt + regist_tm + op_no + key)` uppercase | |

---

### 6.2 PostToHopesJob

**File:** `app/Jobs/PostToHopesJob.php`  
**Automated Test:** `tests/Feature/HopesIntegrationTest.php`

#### Success Scenario

| ID | Skenario | API Response | Expected | Test |
|----|----------|-------------|----------|------|
| JOB-01 | HOPES merespon sukses | `response_code: "00"` | `hopes_sync_status = 'success'`, `hopes_response` = JSON response, `hopes_synced_at` terisi | `testJobHandlesSuccessResponseAndUpdatesStatus` ✅ |
| JOB-02 | Logging activity sukses | `response_code: "00"` | `OnLoggingActivity` ter-fire dengan pesan "HOPES sync success for sub-agenda #..." | ✅ (bagian JOB-01) |

#### Failure Scenario — API Business Error

| ID | Skenario | API Response | Expected | Test |
|----|----------|-------------|----------|------|
| JOB-03 | API merespon error bisnis | `response_code: "01"` (ppk is empty) | `hopes_sync_status = 'failed'`, warning log | `testJobHandlesFailedApiResponseCode` ✅ |
| JOB-04 | API merespon semua error code | `response_code: "02"` s.d. `"11"` | `hopes_sync_status = 'failed'`, `hopes_response` = JSON error | (manual) |

#### Failure Scenario — Network / Infrastructure

| ID | Skenario | Kondisi | Expected | Test |
|----|----------|---------|----------|------|
| JOB-05 | Timeout (attempt 1/3) | `RequestException` | Re-throw exception, error log, **tidak update** status DB | `testJobRethrowsExceptionOnNetworkError` ✅ |
| JOB-06 | Timeout (attempt final, 3/3) | `RequestException` di attempt terakhir | `hopes_sync_status = 'failed'`, `hopes_response = {"error": "..."}`, `OnLoggingActivity` | `testJobUpdatesStatusOnFinalRetryAttempt` ✅ |
| JOB-07 | Non-JSON response (proxy error page) | HTTP 200 tapi body HTML | Error log, jika attempt terakhir → `hopes_sync_status = 'failed'` | (manual) |
| JOB-08 | `failed()` hook | Job masuk `failed_jobs` | Log error permanent | (manual) |

#### Konfigurasi Retry

| ID | Skenario | Expected | Test |
|----|----------|----------|------|
| JOB-09 | Retry config sesuai `hopes.php` | `$job->tries = config('hopes.retry_times', 3)`, `$job->retryAfter = config('hopes.retry_delay', 5)` | `testJobHasCorrectRetryConfiguration` ✅ |

---

### 6.3 SHA1 Signature Generation

**Formula:** `strtoupper(sha1(ppk + regist_dt + regist_tm + op_no + encryption_key))`  
**File:** `tests/Unit/HopesSignatureTest.php`  
**Catatan:** Format `regist_dt` = `yyyy-MM-dd` (ISO 8601), `regist_tm` = `HH:mm:ss`

| ID | Skenario | Expected | Test |
|----|----------|----------|------|
| SIG-01 | Signature valid format | 40 karakter uppercase hex | `testSignatureIsValid40CharUppercaseHex` ✅ |
| SIG-02 | Deterministik | Input sama → output sama | `testSignatureIsDeterministic` ✅ |
| SIG-03 | UAT vs Production berbeda | Key `ABC` ≠ `hanaOnAhoApi` → signature berbeda | `testUatAndProductionSignaturesAreDifferent` ✅ |
| SIG-04 | Formula sesuai implementasi | Concatenation: `ppk + dt + tm + op + key` | `testSignatureFormulaMatchesListenerImplementation` ✅ |
| SIG-05 | Setiap komponen berpengaruh | PPK/tanggal/waktu/op_no/key berbeda → hash berbeda | `testSignatureChangesWhenAnyComponentChanges` ✅ |
| SIG-06 | Urutan concatenation penting | Urutan terbalik → hash berbeda | `testConcatenationOrderMatters` ✅ |
| SIG-07 | Komponen kosong → hash tetap valid | SHA1 dari empty string tetap 40 char | `testSignatureWithEmptyComponentStillProducesValidHash` ✅ |

> **Catatan penting:** Dokumentasi API HOPES mencantumkan signature yang sama (`6A031486512164435A05BD9C8C8F0C0D680382BE`) pada contoh `selectCust` (`regist_tm=14:26:00`) dan `insertAHO` (`regist_tm=14:30:00`). Karena waktu berbeda, hash seharusnya berbeda — ini mengindikasikan signature di dokumentasi adalah **placeholder**. Implementasi sudah benar mengikuti formula, bukan nilai contoh di doc.

---

## 7. Testing: Endpoint Tambahan HOPES

### 7.1 `hopesSelectCust` — Validasi PPK ke HOPES

**Route:** `POST /agenda/{agenda}/select-cust` (atau endpoint yang dikonfigurasi)  
**Lokasi:** `EmployeeAgendaController::hopesSelectCust()` (baris 803)  
**Tujuan:** Memvalidasi nomor PPK dan mendapatkan `cix_no` + `cust_nm` dari HOPES.

| ID | Skenario | Input | Expected |
|----|----------|-------|----------|
| SC-01 | PPK valid | `ppk = "121000012963"` | HTTP 200, body berisi `cix_no`, `cust_nm`, `response_code: "00"` |
| SC-02 | PPK tidak ditemukan | PPK tidak ada di HOPES | HTTP 200 dari HOPES, `response_code ≠ "00"` (Ppk not found data) |
| SC-03 | Request tanpa PPK | `ppk` tidak dikirim | HTTP 422: `ppk field is required` |
| SC-04 | HOPES tidak dapat dihubungi | Network error | HTTP 500: `"Failed to connect to HOPES API: ..."` |
| SC-05 | Signature digenerate dengan benar | Panggil endpoint | Signature di request = `SHA1(ppk + dt + tm + op_no + key)` uppercase |

**Contoh Request yang Dikirim ke HOPES:**
```json
{
  "ppk": "121000012963",
  "scrn_Id": "OPIS",
  "regist_dt": "2026-04-15",
  "regist_tm": "10:30:00",
  "op_no": "1708064",
  "signature": "<SHA1_UPPERCASE>"
}
```

**Contoh Response Sukses:**
```json
{
  "cix_no": "00020491",
  "cust_nm": "ANWAR YULIANTO",
  "response_code": "00",
  "response_desc": "Success",
  "ppk": "121000012963"
}
```

---

### 7.2 `hopesResync` — Force Re-Sync Sub Agenda

**Lokasi:** `EmployeeAgendaController::hopesResync()` (baris 750)  
**Otorisasi:** Pemilik agenda atau admin/supervisor (via `DEmpSubAgendaPolicy::resync()`)

| ID | Skenario | Kondisi | Expected |
|----|----------|---------|----------|
| RS-01 | Re-sync berhasil dipicu | `time_out` terisi, `ppk` + `cix_no` ada, status bukan `pending` | HTTP 200, `hopes_sync_status = 'pending'`, job masuk queue |
| RS-02 | Belum check-out | `time_out = null` | HTTP 422: `"Sub-agenda belum check-out..."` |
| RS-03 | Sedang pending | `hopes_sync_status = 'pending'` | HTTP 409: `"Sync sedang dalam antrian..."` |
| RS-04 | PPK kosong | `ppk = null` | HTTP 422: `"PPK atau cix_no tidak terisi..."` |
| RS-05 | User bukan pemilik | Role `user`, bukan pemilik agenda | HTTP 403 Forbidden |
| RS-06 | Admin dapat re-sync milik siapapun | Role `admin` | HTTP 200 (selalu authorized) |
| RS-07 | Supervisor dapat re-sync milik siapapun | Role `supervisor` | HTTP 200 (selalu authorized) |

---

## 8. Testing: Authorization & Policy

**File:** `app/Policies/DEmpSubAgendaPolicy.php`

| ID | Action | Role `user` | Role `admin`/`supervisor` | Catatan |
|----|--------|------------|--------------------------|---------|
| POL-01 | `create` | Hanya pemilik agenda + branch sesuai `agenda_branch_rule` | ❌ (return `false`) | Admin tidak bisa buat sub-agenda atas nama orang lain |
| POL-02 | `update` | Hanya pemilik agenda | ❌ | — |
| POL-03 | `checkInOut` | Hanya pemilik agenda | ❌ | — |
| POL-04 | `delete` | Hanya pemilik agenda | ❌ | — |
| POL-05 | `resync` | Hanya pemilik agenda | ✅ (selalu boleh) | Admin/supervisor bisa resync milik siapapun |

#### Guard Hapus Sub-Agenda yang Sudah Sukses Sync (GAP-7)

| ID | Skenario | Kondisi | Expected |
|----|----------|---------|----------|
| POL-06 | Hapus sub-agenda sukses HOPES | `hopes_sync_status = 'success'` | HTTP 422: `"Agenda ini tidak dapat dihapus karena sudah tersinkronisasi ke HOPES."` |
| POL-07 | Hapus sub-agenda belum sync | `hopes_sync_status = null` atau `failed` | Berhasil dihapus |
| POL-08 | Hapus sub-agenda pending | `hopes_sync_status = 'pending'` | Berhasil dihapus (hanya block jika `success`) |

---

## 9. Automated Test Results (PHPUnit)

**Tanggal Eksekusi:** 16 Maret 2026  
**Runtime:** PHP 7.4.33 | PHPUnit 8.5.33 | Laravel 7  
**Perintah:** `php vendor/bin/phpunit --filter="Hopes" --no-coverage`  
**Hasil:** **24 tests, 56 assertions — ALL PASSED** (1.57 detik)

### D.1 Unit Test — SHA1 Signature (`tests/Unit/HopesSignatureTest.php`)

| No | Test Case | Durasi | Hasil |
|----|-----------|--------|-------|
| SIG-01 | `testSignatureIsValid40CharUppercaseHex` | 11.97ms | ✅ PASS |
| SIG-02 | `testSignatureIsDeterministic` | 0.11ms | ✅ PASS |
| SIG-03 | `testUatAndProductionSignaturesAreDifferent` | 2.08ms | ✅ PASS |
| SIG-04 | `testSignatureFormulaMatchesListenerImplementation` | 0.13ms | ✅ PASS |
| SIG-05 | `testSignatureChangesWhenAnyComponentChanges` | 0.15ms | ✅ PASS |
| SIG-06 | `testConcatenationOrderMatters` | 0.10ms | ✅ PASS |
| SIG-07 | `testSignatureWithEmptyComponentStillProducesValidHash` | 0.08ms | ✅ PASS |

**Subtotal:** 7 tests, 14 assertions

---

### D.2 Unit Test — Listener Guards (`tests/Unit/Listeners/SyncToHopesHdlrTest.php`)

| No | Test Case | Durasi | Hasil |
|----|-----------|--------|-------|
| LIS-01 | `testSkipsSyncWhenPpkIsNull` | 411.28ms | ✅ PASS |
| LIS-02 | `testSkipsSyncWhenCixNoIsNull` | 48.96ms | ✅ PASS |
| LIS-03 | `testSkipsSyncWhenPpkIsEmptyString` | 59.55ms | ✅ PASS |
| LIS-04 | `testSkipsSyncWhenBothFieldsAreEmpty` | 57.64ms | ✅ PASS |
| LIS-05 | `testDispatchesJobWhenAllHopesFieldsPresent` | 78.20ms | ✅ PASS |
| LIS-06 | `testSkipsSyncWhenEmployeeIsNull` | 51.88ms | ✅ PASS |
| LIS-07 | `testSkipsSyncWhenAgendaNotFound` | 62.83ms | ✅ PASS |

**Subtotal:** 7 tests, 14 assertions

> **Teknik:** Test LIS-05, LIS-06, LIS-07 menggunakan **anonymous class extension** dari `SyncToHopesHdlr` untuk menghindari static Eloquent call (`DEmployeeAgenda::where()`) yang tidak bisa di-mock di Laravel 7. Ini trade-off yang disadari — idealnya Job menerima dependency via DI.

---

### D.3 Feature Test — Integration End-to-End (`tests/Feature/HopesIntegrationTest.php`)

**Alur yang diuji:** Event → Listener Registration → Queue → Job → HTTP POST (Guzzle Mock) → DB Update → Logging

| No | Test Case | Durasi | Hasil |
|----|-----------|--------|-------|
| INT-01 | `testOnSubAgendaCompletedEventCanBeDispatched` | 61.00ms | ✅ PASS |
| INT-02 | `testSyncToHopesHdlrIsRegisteredAsListener` | 55.41ms | ✅ PASS |
| INT-03 | `testPostToHopesJobIsQueuedAfterDispatch` | 52.92ms | ✅ PASS |
| INT-04 | `testJobHandlesSuccessResponseAndUpdatesStatus` | 91.07ms | ✅ PASS |
| INT-05 | `testJobHandlesFailedApiResponseCode` | 50.70ms | ✅ PASS |
| INT-06 | `testJobRethrowsExceptionOnNetworkError` | 54.43ms | ✅ PASS |
| INT-07 | `testJobUpdatesStatusOnFinalRetryAttempt` | 48.92ms | ✅ PASS |
| INT-08 | `testPayloadContainsAllRequiredHopesKeys` | 52.13ms | ✅ PASS |
| INT-09 | `testRequestIsSentAsJsonPostToCorrectEndpoint` | 50.24ms | ✅ PASS |
| INT-10 | `testJobHasCorrectRetryConfiguration` | 50.25ms | ✅ PASS |

**Subtotal:** 10 tests, 28 assertions

---

### D.4 Base Class

| File | Keterangan |
|------|-----------|
| `tests/NoDatabaseTestCase.php` | Base class tanpa migrasi DB — lebih cepat untuk test yang tidak perlu koneksi database |

---

## 10. Manual UAT Scenarios

### Happy Path

| No | Skenario | Langkah | Expected | DB Check |
|----|----------|---------|----------|---------- |
| HP-01 | AHO buat sub-agenda dengan HOPES | Login AHO (id_position=45) → buat sub-agenda dengan ppk, cix_no, main_cd, sub_cd | Record tersimpan dengan field HOPES terisi | `d_emp_sub_agendas`: semua kolom HOPES terisi |
| HP-02 | Non-AHO buat sub-agenda | Login non-AHO → buat sub-agenda (form tidak tampilkan field HOPES) | Record tersimpan, field HOPES = null | `ppk`, `cix_no`, `main_cd`, `sub_cd` = null |
| HP-03 | AHO check-out → HOPES sync sukses | AHO check-out sub-agenda dengan HOPES, queue worker aktif | `hopes_sync_status = 'success'` | `hopes_response` = JSON HOPES, `hopes_synced_at` terisi |
| HP-04 | Verifikasi signature | AHO check-out, cek payload di log | `signature` = `SHA1(ppk+dt+tm+nik+ABC)` uppercase 40 char | Log: `storage/logs/laravel-*.log` |
| HP-05 | Sub kode Janji Bayar + prom_dt | AHO buat sub-agenda dengan `sub_cd=100`, isi `prom_dt` | Tersimpan, `promDt` dikirim ke HOPES | `prom_dt` di DB + `promDt` di `hopes_response` |
| HP-06 | Re-sync manual setelah edit | AHO edit field kritikal setelah sync sukses → re-sync | `hopes_sync_status` → `pending` → (setelah queue) `success` | Status berubah sesuai response HOPES |
| HP-07 | selectCust validasi PPK | Form AHO input PPK → HOPES selectCust dipanggil | `cix_no` + `cust_nm` muncul di form | — |

### Negative Path

| No | Skenario | Langkah | Expected | DB Check |
|----|----------|---------|----------|---------- |
| NP-01 | AHO check-out tanpa sub_cd | Sub-agenda AHO belum diisi sub_cd → check-out | HTTP 422: `"Sub kode aktivitas belum diisi..."` | Tidak ada perubahan status |
| NP-02 | Janji Bayar tanpa prom_dt | `sub_cd=100`, `prom_dt=null` → check-out | HTTP 422: `"Tanggal janji bayar wajib diisi..."` | — |
| NP-03 | HOPES API error response | HOPES merespon `response_code ≠ "00"` | `hopes_sync_status = 'failed'` | `hopes_response` berisi error dari HOPES |
| NP-04 | Hapus sub-agenda sukses HOPES | `hopes_sync_status = 'success'` → delete | HTTP 422: tidak bisa dihapus | Record tetap ada |
| NP-05 | Re-sync saat pending | `hopes_sync_status = 'pending'` → resync | HTTP 409: `"Sync sedang dalam antrian..."` | — |
| NP-06 | Non-AHO coba akses form HOPES | Login non-AHO, buat sub-agenda dengan field HOPES di request | Field HOPES diabaikan (SubAgendaService), `ppk` di DB = null | — |

### Edge Cases

| No | Skenario | Langkah | Expected |
|----|----------|---------|----------|
| EC-01 | API HOPES timeout | Queue worker aktif, simulasikan timeout | Retry 3x, lalu `failed`. Error log per-attempt. |
| EC-02 | API HOPES down | Matikan HOPES server | Retry 3x → masuk `failed_jobs`. Method `failed()` mencatat log. |
| EC-03 | Retry sukses di attempt ke-2 | Flaky network: attempt-1 fail, attempt-2 sukses | `hopes_sync_status = 'success'` setelah retry ke-2 |
| EC-04 | Concurrent check-out (2 AHO) | 2 AHO check-out bersamaan | 2 Job independen, signature unik (beda op_no + timestamp) |
| EC-05 | Karakter special di konten | Description berisi `"Rp 1.000.000 & 'catatan'"` | JSON ter-encode benar, HOPES terima tanpa error |
| EC-06 | Edit field non-kritikal | Ubah `cust_nm` saja (bukan field kritikal) | Tidak ada re-sync |
| EC-07 | Tambah posisi AHO baru di env | Ubah `HOPES_POSITIONS=45,46`, restart | Posisi 46 sekarang pakai `AHOSubAgendaService` |

### Data Integrity Checks

| No | Query Verifikasi | Expected |
|----|-----------------|----------|
| DI-01 | `SELECT hopes_sync_status, hopes_response FROM d_emp_sub_agendas WHERE id = ?` | `response_code="00"` ↔ `hopes_sync_status="success"` |
| DI-02 | `json_decode($hopes_response)` di PHP | JSON valid, berisi `response_code`, `response_desc` |
| DI-03 | `SELECT hopes_synced_at FROM d_emp_sub_agendas WHERE id = ?` | Timestamp sesuai waktu eksekusi Job |
| DI-04 | `SELECT * FROM activity_log WHERE log_name = 'hopes_sync'` | Ada record untuk setiap sync, pesan konsisten dengan status |
| DI-05 | Sub-agenda non-AHO: cek kolom HOPES | Semua kolom HOPES = null |
| DI-06 | `SELECT * FROM jobs WHERE payload LIKE '%PostToHopesJob%'` | Saat dispatch: record ada. Setelah selesai: record hilang. |

---

## 11. Coverage Matrix

| Skenario | Automated Test | Status |
|----------|----------------|--------|
| Store AHO berhasil | — | ⚠️ Perlu test unit AHOSubAgendaService |
| Store non-AHO berhasil | — | ⚠️ Perlu test unit SubAgendaService |
| Validasi `prom_dt` required jika sub_cd=100 | — | ⚠️ Perlu test unit |
| Deteksi re-sync field kritikal berubah (GAP-1) | — | ⚠️ Perlu test unit |
| Re-sync tidak duplikat saat pending (GAP-2) | — | ⚠️ Perlu test unit |
| Service resolution AHO vs non-AHO | — | ⚠️ Perlu test feature |
| Guard: ppk null → skip sync | `testSkipsSyncWhenPpkIsNull` | ✅ |
| Guard: cix_no null → skip sync | `testSkipsSyncWhenCixNoIsNull` | ✅ |
| Guard: ppk empty string | `testSkipsSyncWhenPpkIsEmptyString` | ✅ |
| Guard: employee null | `testSkipsSyncWhenEmployeeIsNull` | ✅ |
| Guard: agenda null | `testSkipsSyncWhenAgendaNotFound` | ✅ |
| Dispatch job ketika semua field ada | `testDispatchesJobWhenAllHopesFieldsPresent` | ✅ |
| Job sukses → update status 'success' | `testJobHandlesSuccessResponseAndUpdatesStatus` | ✅ |
| Job API error → update status 'failed' | `testJobHandlesFailedApiResponseCode` | ✅ |
| Job network error → re-throw exception | `testJobRethrowsExceptionOnNetworkError` | ✅ |
| Job final attempt → update 'failed' | `testJobUpdatesStatusOnFinalRetryAttempt` | ✅ |
| Payload berisi semua 12 required keys | `testPayloadContainsAllRequiredHopesKeys` | ✅ |
| POST ke endpoint /insertAHO | `testRequestIsSentAsJsonPostToCorrectEndpoint` | ✅ |
| Retry config dari hopes.php | `testJobHasCorrectRetryConfiguration` | ✅ |
| Signature format valid | `testSignatureIsValid40CharUppercaseHex` | ✅ |
| Signature deterministik | `testSignatureIsDeterministic` | ✅ |
| UAT ≠ Production signature | `testUatAndProductionSignaturesAreDifferent` | ✅ |
| Formula signature benar | `testSignatureFormulaMatchesListenerImplementation` | ✅ |
| Setiap komponen berpengaruh pada hash | `testSignatureChangesWhenAnyComponentChanges` | ✅ |
| Urutan concatenation penting | `testConcatenationOrderMatters` | ✅ |
| Komponen kosong → hash valid | `testSignatureWithEmptyComponentStillProducesValidHash` | ✅ |
| Retry sukses di attempt ke-2 | — | 🔴 Manual only |
| Concurrent check-out | — | 🔴 Manual only |
| Karakter special di payload | — | 🔴 Manual only |
| `hopes_synced_at` timestamp accuracy | — | 🔴 Manual only (butuh DB assertion) |
| `activity_log` konsistensi | — | 🔴 Manual only |
| Block delete jika sync sukses (GAP-7) | — | ⚠️ Perlu test feature |
| Policy resync (owner vs admin) | — | ⚠️ Perlu test feature |
| hopesSelectCust endpoint | — | ⚠️ Perlu test feature |
| hopesResync endpoint | — | ⚠️ Perlu test feature |

**Legend:**
- ✅ = Covered automated test
- ⚠️ = Belum ada test — direkomendasikan
- 🔴 = Hanya bisa diuji manual

---

## 12. Cara Menjalankan Tests

### Menjalankan Semua Test HOPES

```bash
# Jalankan semua test yang berhubungan HOPES
php vendor/bin/phpunit --filter="Hopes" --no-coverage

# Dengan output detail (testdox)
php vendor/bin/phpunit --filter="Hopes" --no-coverage --testdox --verbose
```

### Menjalankan Per Suite

```bash
# Unit: SHA1 Signature Generation
php vendor/bin/phpunit --filter="HopesSignatureTest" --no-coverage

# Unit: Listener Guard Clauses
php vendor/bin/phpunit --filter="SyncToHopesHdlrTest" --no-coverage

# Feature: End-to-End Integration (Guzzle Mock)
php vendor/bin/phpunit --filter="HopesIntegrationTest" --no-coverage
```

### Menjalankan Semua Test Project

```bash
php vendor/bin/phpunit --no-coverage
```

### Manual Test dengan Artisan Command

```bash
# Monitor queue
php artisan hopes:monitor

# Manual test koneksi HOPES (jika ada command)
php artisan hopes:test-manual
```

### Monitoring Queue

```bash
# Jalankan queue worker (foreground)
php artisan queue:work --tries=3 --delay=5 --queue=default

# Cek jobs yang pending
SELECT * FROM jobs WHERE payload LIKE '%PostToHopesJob%';

# Cek jobs yang failed
SELECT * FROM failed_jobs ORDER BY failed_at DESC;

# Cek log harian
tail -f storage/logs/laravel-$(date +%Y-%m-%d).log | grep HOPES
```

---

## Sign-Off

| Role | Nama | Tanggal | Status |
|------|------|---------|--------|
| QA Engineer | | | |
| Backend Developer | | | |
| Product Owner | | | |
