diff --git a/.env.example b/.env.example index 78ccc0d3..393f4ed1 100644 --- a/.env.example +++ b/.env.example @@ -80,6 +80,13 @@ LONGITUDE_MAP = 115.046600 API_DATABASE_GABUNGAN_HOST=http://127.0.0.1:8001 SANCTUM_STATEFUL_DOMAINS=localhost +# DataSpasial - API Satu Data (App\Services\DataSpasialApiService) +# Base URL & token mengikuti pengaturan database gabungan (API_DATABASE_GABUNGAN_HOST +# dan Setting `database_gabungan_api_key`); TIDAK ada env khusus untuk keduanya. +# Batas waktu permintaan (detik) & masa cache baca (detik, 0 = nonaktif). +API_DATASPASIAL_TIMEOUT=10 +API_DATASPASIAL_CACHE_TTL=300 + # OTP Configuration OTP_TOKEN_EXPIRES_MINUTES=5 OTP_MAX_VERIFICATION_ATTEMPTS=3 diff --git a/app/Enums/DataSpasialReason.php b/app/Enums/DataSpasialReason.php new file mode 100644 index 00000000..d141835f --- /dev/null +++ b/app/Enums/DataSpasialReason.php @@ -0,0 +1,78 @@ + 'DataSpasial', + 'icon' => 'fas fa-map', + 'permission' => 'dataspasial', + 'submenu' => [ + [ + 'icon' => 'far fa-fw fa-circle', + 'text' => 'Skema DataSpasial', + 'url' => 'dataspasial/skema', + 'permission' => 'dataspasial-skema', + ], + [ + 'icon' => 'far fa-fw fa-circle', + 'text' => 'Peta Gabungan', + 'url' => 'dataspasial/peta', + 'permission' => 'dataspasial-peta', + ], + [ + 'icon' => 'far fa-fw fa-circle', + 'text' => 'Dasbor DataSpasial', + 'url' => 'dataspasial/dasbor', + 'permission' => 'dataspasial-dasbor', + ], + [ + 'icon' => 'far fa-fw fa-circle', + 'text' => 'Impor Data', + 'url' => 'dataspasial/impor', + 'permission' => 'dataspasial-impor', + ], + [ + 'icon' => 'far fa-fw fa-circle', + 'text' => 'Monitoring', + 'url' => 'dataspasial/monitoring', + 'permission' => 'dataspasial-monitoring', + ], + ], + ], [ 'text' => 'SOTK', 'icon' => 'fa fa-tags', diff --git a/app/Enums/StatusSkemaEditor.php b/app/Enums/StatusSkemaEditor.php new file mode 100644 index 00000000..7132306b --- /dev/null +++ b/app/Enums/StatusSkemaEditor.php @@ -0,0 +1,75 @@ +value === self::Draf; + } + + /** + * Tombol tambah/hapus/urut isian hanya aktif pada keadaan draf (FR-008a). + */ + public function bolehUbah(): bool + { + return $this->value === self::Draf; + } + + /** + * Menyusun keadaan editor dari skema yang dipilih. + * + * @param array{status: string, jumlah_skema: int} $skema + */ + public static function dari(array $skema): self + { + return match ($skema['status'] ?? '') { + self::Terbit => self::fromValue(self::Terbit), + self::Draf => self::fromValue(self::Draf), + default => self::fromValue(($skema['jumlah_skema'] ?? 0) > 0 ? self::Terbit : self::Kosong), + }; + } +} diff --git a/app/Enums/TipeIsian.php b/app/Enums/TipeIsian.php new file mode 100644 index 00000000..257fa0dc --- /dev/null +++ b/app/Enums/TipeIsian.php @@ -0,0 +1,52 @@ +value === self::Pilihan; + } + + /** + * Satuan relevan hanya untuk tipe yang menyimpan besaran (FR-010). + */ + public function memakaiSatuan(): bool + { + return $this->value === self::Angka; + } +} diff --git a/app/Exceptions/DataSpasialUnavailableException.php b/app/Exceptions/DataSpasialUnavailableException.php new file mode 100644 index 00000000..aadd57a0 --- /dev/null +++ b/app/Exceptions/DataSpasialUnavailableException.php @@ -0,0 +1,42 @@ +value, + 0, + $previous + ); + } + + /** + * Pesan yang aman ditampilkan ke pengguna akhir (FR-014). + */ + public function userMessage(): string + { + return DataSpasialReason::userMessage(); + } +} diff --git a/app/Http/Controllers/DataSpasial/DasborDataSpasialController.php b/app/Http/Controllers/DataSpasial/DasborDataSpasialController.php new file mode 100644 index 00000000..ad65c658 --- /dev/null +++ b/app/Http/Controllers/DataSpasial/DasborDataSpasialController.php @@ -0,0 +1,38 @@ +dataspasial->ringkasan($request->only([ + 'sektor_id', + 'periode', + 'kode_kabupaten', + 'kode_kecamatan', + 'kode_desa', + ])); + } +} diff --git a/app/Http/Controllers/DataSpasial/DataSpasialController.php b/app/Http/Controllers/DataSpasial/DataSpasialController.php new file mode 100644 index 00000000..5fb1565f --- /dev/null +++ b/app/Http/Controllers/DataSpasial/DataSpasialController.php @@ -0,0 +1,51 @@ + $filters + */ + abstract protected function fetch(Request $request): DataSpasialResult; + + public function index(Request $request): View + { + return view($this->viewName(), [ + 'title' => $this->pageTitle(), + 'result' => $this->fetch($request), + ]); + } +} diff --git a/app/Http/Controllers/DataSpasial/ImporDataController.php b/app/Http/Controllers/DataSpasial/ImporDataController.php new file mode 100644 index 00000000..0b566665 --- /dev/null +++ b/app/Http/Controllers/DataSpasial/ImporDataController.php @@ -0,0 +1,36 @@ +dataspasial->listFasilitas($request->only([ + 'sektor_id', + 'skema_versi_id', + 'kode_desa', + ])); + } +} diff --git a/app/Http/Controllers/DataSpasial/MonitoringController.php b/app/Http/Controllers/DataSpasial/MonitoringController.php new file mode 100644 index 00000000..cbba0982 --- /dev/null +++ b/app/Http/Controllers/DataSpasial/MonitoringController.php @@ -0,0 +1,39 @@ +dataspasial->adopsi($request->only([ + 'sektor_id', + 'skema_versi_id', + 'status_adopsi', + 'kode_kabupaten', + 'kode_kecamatan', + 'kode_desa', + ])); + } +} diff --git a/app/Http/Controllers/DataSpasial/PetaGabunganController.php b/app/Http/Controllers/DataSpasial/PetaGabunganController.php new file mode 100644 index 00000000..58998df9 --- /dev/null +++ b/app/Http/Controllers/DataSpasial/PetaGabunganController.php @@ -0,0 +1,37 @@ +dataspasial->geojsonGabungan($request->only([ + 'sektor_id', + 'kode_kabupaten', + 'kode_kecamatan', + 'kode_desa', + ])); + } +} diff --git a/app/Http/Controllers/DataSpasial/SkemaDataSpasialController.php b/app/Http/Controllers/DataSpasial/SkemaDataSpasialController.php new file mode 100644 index 00000000..84f66e3c --- /dev/null +++ b/app/Http/Controllers/DataSpasial/SkemaDataSpasialController.php @@ -0,0 +1,259 @@ +dataspasial->listSektor(['page[size]' => 200]); + } + + public function index(Request $request): View + { + // ⚠️ KUNJUNGAN PERTAMA (FR-007a-1/Q7): yang dipanggil HANYA daftar sektor. + $resultSektor = $this->fetch($request); + $daftarSektor = $this->editor->petakanDaftarSektor($resultSektor->data); + + $sektorTerpilih = $this->sektorTerpilih($request, $daftarSektor); + + // ⚠️ Q22 — tidak ada lagi daftar skema. Setelah sektor dipilih, editor + // langsung memuat isian skema terakhir via satu pemanggilan + // `GET /dataspasial/skema?sektor={kode}` (parameter wajib). + $isian = []; + $skemaTerpilih = null; + + if ($sektorTerpilih !== null) { + $resultSkema = $this->dataspasial->listSkema([ + 'sektor' => $sektorTerpilih['kode'], + ]); + + [$isian] = $this->muatIsian($resultSkema); + + $item = is_array($resultSkema?->first()) ? $resultSkema->first() : null; + $atribut = is_array($item['attributes'] ?? null) ? $item['attributes'] : []; + $versi = is_array($atribut['versi_skema'] ?? null) ? $atribut['versi_skema'] : []; + $skemaTerpilih = [ + 'status' => (string) ($versi['status'] ?? ''), + 'jumlah_skema' => 1, + ]; + } + + // Status global: bila sektor tidak dapat diperoleh, seluruh tombol mati. + if ($resultSektor->isUnavailable()) { + $statusEditor = StatusSkemaEditor::BelumTersedia(); + } else { + $statusEditor = $this->editor->statusEditor($skemaTerpilih, $daftarSektor); + } + + return view($this->viewName(), [ + 'title' => $this->pageTitle(), + 'resultSektor' => $resultSektor, + 'daftarSektor' => $daftarSektor, + 'sektorTerpilih' => $sektorTerpilih, + 'skemaTerpilih' => $skemaTerpilih, + 'isian' => $isian, + 'statusEditor' => $statusEditor, + ]); + } + + /** + * Simpan draf skema (FR-029 … FR-033, Q18/Q19/Q20/Q23). + * + * Sektor dan seluruh isian dikirim dalam satu permintaan + * `POST /dataspasial/skema`. Kode sektor dibangkitkan sistem dari nama + * (Q5) dan dikirim sebagai `sektor`; nama sektor dikirim sebagai + * `sektor_nama`. Minimal satu isian wajib (Q23). + * + * Q21 — selalu via AJAX (JSON); balas JSON untuk request AJAX, redirect + * untuk formulir biasa. Kegagalan → pesan "data sementara tidak tersedia", + * isi editor utuh, tanpa muat ulang halaman. + */ + public function storeDraf(StoreDrafSkemaRequest $request): RedirectResponse|JsonResponse + { + $validated = $request->validated(); + $sektorNama = trim((string) ($validated['sektor_nama'] ?? '')); + + // ⚠️ FR-007d-5 — kode sektor TIDAK PERNAH berubah karena nama diubah. + // Kode yang dikirim klien dipakai apa adanya; kode baru hanya + // dibangkitkan dari nama bila klien tidak mengirimnya, yaitu pada mode + // "sektor baru" (Q26/FR-008f). + $sektorKode = trim((string) ($validated['sektor'] ?? '')); + + if ($sektorKode === '') { + $sektorKode = $this->editor->kodeSektorDariNama($sektorNama); + } + + $body = $this->editor->composeDrafPayload($validated['isian'] ?? [], [ + 'sektor' => $sektorKode, + 'sektor_nama' => $sektorNama, + 'keterangan' => $validated['keterangan'] ?? null, + ]); + + $result = $this->dataspasial->buatDrafSkema($body); + + if ($result->isUnavailable()) { + Log::warning('DataSpasial: penyimpanan draf gagal', [ + 'sektor' => $sektorKode, + 'reason' => $result->reason?->value, + 'http_status' => $result->httpStatus, + ]); + + $pesan = $result->userMessage() ?: 'Data sementara tidak tersedia.'; + + if ($request->ajax() || $request->wantsJson()) { + return response()->json(['success' => false, 'message' => $pesan], 503); + } + + return back() + ->withInput() + ->with('error', $pesan); + } + + if ($request->ajax() || $request->wantsJson()) { + return response()->json([ + 'success' => true, + 'message' => 'Draf skema berhasil disimpan.', + 'sektor' => $sektorKode, + 'data' => $result->first(), + ]); + } + + return redirect() + ->route('dataspasial.skema', array_filter([ + 'sektor' => $sektorKode, + ])) + ->with('success', 'Draf skema berhasil disimpan.'); + } + + /** + * Terbitkan draf skema menjadi versi `terbit` (US8, FR-041 … FR-044). + * + * ⚠️ UTANG KONTRAK U6 — `POST /dataspasial/skema/{sektor}/terbit` belum ada + * di kontrak v0.1.0 maupun API hidup. Kegagalan **tidak boleh** mengubah + * draf yang sudah ada (FR-043). + * + * ⚠️ Path memakai **kode** sektor, bukan id numerik. + */ + public function terbit(TerbitSkemaRequest $request): RedirectResponse + { + $validated = $request->validated(); + + $sektorKode = trim((string) $validated['sektor']); + + $result = $this->dataspasial->terbitkanSkema($sektorKode, array_filter([ + 'catatan_perubahan' => $validated['catatan_perubahan'] ?? null, + ], fn ($v): bool => filled($v))); + + if ($result->isUnavailable()) { + Log::warning('DataSpasial: penerbitan skema gagal', [ + 'sektor' => $sektorKode, + 'reason' => $result->reason?->value, + 'http_status' => $result->httpStatus, + ]); + + return back() + ->withInput() + ->with('error', $result->userMessage()); + } + + return redirect() + ->route('dataspasial.skema', array_filter([ + 'sektor' => $sektorKode, + 'skema' => $result->first()['id'] ?? $validated['skema'] ?? null, + ])) + ->with('success', 'Skema berhasil diterbitkan.'); + } + + /** + * @param array> $daftarSektor + * @return array|null + */ + private function sektorTerpilih(Request $request, array $daftarSektor): ?array + { + $kode = (string) $request->query('sektor', ''); + + if ($kode === '') { + return null; + } + + foreach ($daftarSektor as $sektor) { + if ($sektor['kode'] === $kode) { + return $sektor; + } + } + + return null; + } + + /** + * Muat isian skema terpilih beserta pemetaannya. + * + * @param array|null $skemaTerpilih + * @return array{0: array>, 1: DataSpasialResult|null} + */ + private function muatIsian(?DataSpasialResult $resultSkema): array + { + // ⚠️ K2 — endpoint `GET /skema/{id}` tidak ada (404). Isian ikut pada + // respons `GET /skema?sektor=`. Bila respons belum ada, tidak ada yang + // dimuat dan TIDAK ada request sia-sia yang dikirim. + $item = is_array($resultSkema?->first()) ? $resultSkema->first() : null; + + if ($item === null) { + return [[], $resultSkema]; + } + + $atribut = is_array($item['attributes'] ?? null) ? $item['attributes'] : []; + $itemIsian = is_array($atribut['isian'] ?? null) ? $atribut['isian'] : []; + + return [$this->editor->petakanDaftarIsian($itemIsian), $resultSkema]; + } +} diff --git a/app/Http/Requests/DataSpasial/StoreDrafSkemaRequest.php b/app/Http/Requests/DataSpasial/StoreDrafSkemaRequest.php new file mode 100644 index 00000000..d958ad7b --- /dev/null +++ b/app/Http/Requests/DataSpasial/StoreDrafSkemaRequest.php @@ -0,0 +1,251 @@ + + */ + public function rules(): array + { + return [ + // ⚠️ Q5/Q19/Q26 — `sektor` adalah KODE sektor yang **dibangkitkan + // backend dari `sektor_nama`**, jadi untuk sektor baru klien + // mengirimnya kosong; yang wajib dari klien adalah `sektor_nama`. + 'sektor' => ['nullable', 'string', 'max:100'], + 'sektor_nama' => ['required', 'string', 'max:100'], + 'keterangan' => ['nullable', 'string', 'max:500'], + + // FR-019/Q23: sebuah sektor tidak boleh memiliki nol isian — + // setidaknya satu isian wajib dalam satu permintaan penyimpanan. + 'isian' => ['required', 'array', 'min:1'], + + // FR-017/Q14: `kode` adalah nilai TURUNAN dari `label`, bukan + // ketikan admin. Aturan di bawah memverifikasi INTEGRITAS nilai + // turunan itu, karena FR-018 tetap mewajibkan kode hasil bangkit + // memenuhi pola snake_case. + 'isian.*.kode' => ['required', 'string', 'max:100', 'regex:/^[a-z][a-z0-9_]*$/'], + 'isian.*.label' => ['required', 'string', 'max:255'], + 'isian.*.tipe' => ['required', 'string', Rule::in(['angka', 'teks', 'pilihan', 'ya_tidak', 'tanggal'])], + 'isian.*.wajib' => ['boolean'], + 'isian.*.satuan' => ['nullable', 'string', 'max:50'], + // FR-019: isian wajib tipe `pilihan` harus punya ≥1 butir. + 'isian.*.opsi' => ['nullable', 'array'], + 'isian.*.opsi.*' => ['string', 'max:100'], + 'isian.*.urutan' => ['required', 'integer', 'min:1'], + + // ⚠️ R12 — nama field INTERNAL sesuai tampilan/view (`popupPeta`, + // `sebagaiFilter`, `sebagaiIndikator`, `tabelSumber`, `kolomSumber`). + // Terjemahan ke nama tulis DataSpasial (`tampil_di_*`, + // `tabel_sumber`, `kolom_sumber`) hanya di SkemaEditorService. + 'isian.*.popupPeta' => ['boolean'], + 'isian.*.sebagaiFilter' => ['boolean'], + 'isian.*.sebagaiIndikator' => ['boolean'], + // ⚠️ Bentuk API hanya punya SATU nilai `rujukan_sumber` + // ("tabel.kolom", §6a.4) —ini rujukan yang dikirim klien. + // `tabelSumber`/`kolomSumber` tetap diterima agar klien lama / + // ISIan yang dipecah di sisi tampilan tetap kompatibel. + 'isian.*.sumber' => ['nullable', 'string', Rule::in(['openSID', 'dataPresisi', 'impor', 'manual'])], + 'isian.*.rujukan' => ['nullable', 'string', 'max:201'], + 'isian.*.tabelSumber' => ['nullable', 'string', 'max:100'], + 'isian.*.kolomSumber' => ['nullable', 'string', 'max:100'], + ]; + } + + /** + * Aturan yang tidak bisa dinyatakan sebagai rule Laravel biasa. + */ + public function withValidator(Validator $validator): void + { + $validator->after(function (Validator $validator): void { + $this->tolakKodeBentrok($validator); + $this->tolakPilihanKosong($validator); + $this->tolakRujukanKosong($validator); + }); + } + + /** + * FR-022/Q20 — sumber nilai selain `manual` wajib punya rujukan. + * + * ⚠️ Rujukan diterima sebagai `rujukan` ("tabel.kolom"); bila klien hanya + * mengirim `tabelSumber` + `kolomSumber`, keduanya disambung di sini — + * bentuk akhir yang disimpan adalah `rujukan_sumber`. + */ + private function tolakRujukanKosong(Validator $validator): void + { + $isian = $this->input('isian'); + + if (! is_array($isian)) { + return; + } + + foreach ($isian as $index => $baris) { + if (! is_array($baris)) { + continue; + } + + $sumber = trim((string) ($baris['sumber'] ?? '')); + + if ($sumber === '' || $sumber === 'manual') { + continue; + } + + $rujukan = trim((string) ($baris['rujukan'] ?? '')); + + if ($rujukan === '') { + $rujukan = implode('.', array_filter([ + trim((string) ($baris['tabelSumber'] ?? '')), + trim((string) ($baris['kolomSumber'] ?? '')), + ], static fn (string $bagian): bool => $bagian !== '')); + } + + if ($rujukan === '') { + $validator->errors()->add( + "isian.{$index}.rujukan", + 'Rujukan sumber wajib diisi bila sumber nilai bukan manual.' + ); + } + } + } + + /** + * FR-018/Q17 — `label` dan `kode` yang sama lebih dari satu kali di dalam + * SATU versi yang sedang disusun. + * + * ⚠️ Cakupannya **satu versi** (`sektor` + `nomor_versi`), bukan lintas + * versi: satu request hanya berisi satu versi, dan draf berikutnya disalin + * dari versi sebelumnya sehingga label yang sama harus boleh dipakai lagi + * pada versi yang lebih baru (Q17). + */ + private function tolakKodeBentrok(Validator $validator): void + { + $isian = $this->input('isian'); + + if (! is_array($isian)) { + return; + } + + $terlihatKode = []; + $terlihatLabel = []; + + foreach ($isian as $index => $baris) { + if (! is_array($baris)) { + continue; + } + + $kode = trim((string) ($baris['kode'] ?? '')); + + if ($kode !== '') { + if (isset($terlihatKode[$kode])) { + $validator->errors()->add( + "isian.{$index}.kode", + "Kode isian \"{$kode}\" sudah dipakai isian lain pada versi ini. " + .'Ubah label isian agar kode berbeda — kode tidak diberi akhiran angka otomatis.' + ); + } else { + $terlihatKode[$kode] = true; + } + } + + $label = trim((string) ($baris['label'] ?? '')); + + if ($label !== '') { + if (isset($terlihatLabel[$label])) { + $validator->errors()->add( + "isian.{$index}.label", + "Label isian \"{$label}\" sudah dipakai isian lain pada versi ini. " + .'Label isian harus unik di dalam satu versi.' + ); + } else { + $terlihatLabel[$label] = true; + } + } + } + } + + /** + * FR-019 — isian (wajib maupun tidak) tipe `pilihan` wajib punya butir. + */ + private function tolakPilihanKosong(Validator $validator): void + { + $isian = $this->input('isian'); + + if (! is_array($isian)) { + return; + } + + foreach ($isian as $index => $baris) { + if (! is_array($baris) || ($baris['tipe'] ?? null) !== 'pilihan') { + continue; + } + + $opsi = array_values(array_filter( + is_array($baris['opsi'] ?? null) ? $baris['opsi'] : [], + static fn ($item): bool => is_string($item) && trim($item) !== '', + )); + + if ($opsi === []) { + $validator->errors()->add( + "isian.{$index}.opsi", + 'Isian tipe "pilihan" harus memiliki minimal satu butir pilihan.' + ); + } + } + } + + /** + * @return array + */ + public function messages(): array + { + return [ + 'isian.*.kode.required' => 'Kode isian wajib diisi (dibangkitkan otomatis dari label).', + 'isian.*.kode.max' => 'Kode isian maksimal 100 karakter.', + 'isian.required' => 'Sektor harus memiliki minimal satu isian.', + 'isian.min' => 'Sektor harus memiliki minimal satu isian.', + 'sektor_nama.required' => 'Nama sektor wajib diisi.', + ]; + } + + /** + * @return array + */ + public function attributes(): array + { + return [ + 'isian' => 'isian', + 'isian.*.kode' => 'kode isian', + 'isian.*.label' => 'label isian', + 'isian.*.tipe' => 'tipe isian', + 'isian.*.opsi' => 'pilihan', + 'isian.*.urutan' => 'urutan', + ]; + } +} diff --git a/app/Http/Requests/DataSpasial/TerbitSkemaRequest.php b/app/Http/Requests/DataSpasial/TerbitSkemaRequest.php new file mode 100644 index 00000000..989687ad --- /dev/null +++ b/app/Http/Requests/DataSpasial/TerbitSkemaRequest.php @@ -0,0 +1,66 @@ + + */ + public function rules(): array + { + return [ + 'sektor' => ['required', 'string', 'max:100'], + 'skema' => ['nullable', 'integer', 'min:1'], + 'catatan_perubahan' => ['nullable', 'string', 'max:500'], + ]; + } + + /** + * @return array + */ + public function messages(): array + { + return [ + 'sektor.required' => 'Sektor wajib diisi.', + 'skema.integer' => 'Skema tidak valid.', + 'skema.min' => 'Skema tidak valid.', + 'catatan_perubahan.max' => 'Catatan perubahan maksimal 500 karakter.', + ]; + } + + /** + * @return array + */ + public function attributes(): array + { + return [ + 'sektor' => 'sektor', + 'skema' => 'skema', + 'catatan_perubahan' => 'catatan perubahan', + ]; + } +} diff --git a/app/Services/ArtikelService.php b/app/Services/ArtikelService.php index 0b0874f3..e71dde3f 100644 --- a/app/Services/ArtikelService.php +++ b/app/Services/ArtikelService.php @@ -230,4 +230,3 @@ public function clearAllCache(): void Cache::forget($this->cacheRegistryKey); } } - diff --git a/app/Services/BaseApiService.php b/app/Services/BaseApiService.php index 86b70fc1..fcbf0580 100644 --- a/app/Services/BaseApiService.php +++ b/app/Services/BaseApiService.php @@ -24,7 +24,10 @@ public function __construct() $this->header = [ 'Accept' => 'application/ld+json', 'Content-Type' => 'application/json', - 'Authorization' => 'Bearer '.$this->settings['database_gabungan_api_key'] ?? '', + // Catatan: pada PHP, '.' mengikat lebih kuat daripada '??', sehingga + // 'Bearer '.$x['k'] ?? '' tetap menghitung akses array tanpa + // perlindungan. Tanda kurung diperlukan. + 'Authorization' => 'Bearer '.($this->settings['database_gabungan_api_key'] ?? ''), ]; $this->baseUrl = config('app.databaseGabunganUrl'); $this->kodeKecamatan = str_replace('.', '', config('profil.kecamatan_id')); diff --git a/app/Services/CaptchaService.php b/app/Services/CaptchaService.php index bbc4cdf6..9a41c632 100644 --- a/app/Services/CaptchaService.php +++ b/app/Services/CaptchaService.php @@ -2,16 +2,15 @@ namespace App\Services; +use App\Models\Setting; use Illuminate\Http\Request; +use Illuminate\Support\Facades\RateLimiter; use Illuminate\Support\Facades\Session; class CaptchaService { /** * Validate captcha using Mews\Captcha - * - * @param string $code - * @return bool */ public function validate(string $code): bool { @@ -20,8 +19,6 @@ public function validate(string $code): bool /** * Get captcha image URL - * - * @return string */ public function getImageUrl(): string { @@ -30,55 +27,50 @@ public function getImageUrl(): string /** * Check if CAPTCHA is enabled - * - * @return bool */ public function isEnabled(): bool { - $settings = \App\Models\Setting::pluck('value', 'key')->toArray(); + $settings = Setting::pluck('value', 'key')->toArray(); + return filter_var($settings['captcha_enabled'] ?? true, FILTER_VALIDATE_BOOLEAN); } /** * Check if captcha should be shown based on failed attempts - * - * @param Request $request - * @return bool */ public function shouldShow(Request $request): bool { - $settings = \App\Models\Setting::pluck('value', 'key')->toArray(); + $settings = Setting::pluck('value', 'key')->toArray(); $enabled = filter_var($settings['captcha_enabled'] ?? true, FILTER_VALIDATE_BOOLEAN); $threshold = (int) ($settings['captcha_threshold'] ?? 2); - if (!$enabled) { + if (! $enabled) { return false; } $key = $this->getRateLimitKey($request); - $attempts = \Illuminate\Support\Facades\RateLimiter::attempts($key); + $attempts = RateLimiter::attempts($key); return $attempts >= $threshold; } /** * Get captcha configuration from database - * - * @return array */ public function getCaptchaConfig(): array { - $settings = \App\Models\Setting::pluck('value', 'key')->toArray(); + $settings = Setting::pluck('value', 'key')->toArray(); $type = $settings['captcha_type'] ?? 'builtin'; // jika menggunakan recaptcha v3, pastikan sitekey dan secret key terisi - if($type == 'google'){ - if(empty($settings['google_recaptcha_site_key']) or empty($settings['google_recaptcha_secret_key'])){ + if ($type == 'google') { + if (empty($settings['google_recaptcha_site_key']) or empty($settings['google_recaptcha_secret_key'])) { $type = 'builtin'; } } + return [ 'enabled' => filter_var($settings['captcha_enabled'] ?? true, FILTER_VALIDATE_BOOLEAN), -'type' => $type, + 'type' => $type, 'threshold' => (int) ($settings['captcha_threshold'] ?? 2), 'google_site_key' => $settings['google_recaptcha_site_key'] ?? '', 'google_secret_key' => $settings['google_recaptcha_secret_key'] ?? '', @@ -87,9 +79,6 @@ public function getCaptchaConfig(): array /** * Get rate limit key for request - * - * @param Request $request - * @return string */ protected function getRateLimitKey(Request $request): string { @@ -101,32 +90,24 @@ protected function getRateLimitKey(Request $request): string /** * Increment failed attempts - * - * @param Request $request - * @return void */ public function incrementFailedAttempts(Request $request): void { $key = $this->getRateLimitKey($request); - \Illuminate\Support\Facades\RateLimiter::hit($key, 300); + RateLimiter::hit($key, 300); } /** * Reset failed attempts - * - * @param Request $request - * @return void */ public function resetFailedAttempts(Request $request): void { $key = $this->getRateLimitKey($request); - \Illuminate\Support\Facades\RateLimiter::clear($key); + RateLimiter::clear($key); } /** * Clear captcha session - * - * @return void */ public function clearCaptchaSession(): void { diff --git a/app/Services/ConfigApiService.php b/app/Services/ConfigApiService.php index 6c21e796..0dd7f3c2 100644 --- a/app/Services/ConfigApiService.php +++ b/app/Services/ConfigApiService.php @@ -2,6 +2,7 @@ namespace App\Services; +use App\Models\Identitas; use Illuminate\Support\Facades\Cache; class ConfigApiService extends BaseApiService @@ -14,11 +15,11 @@ public function desa(array $filters = []) return Cache::remember($cacheKey, $this->cacheTtl, function () use ($filters) { $data = $this->apiRequest('/api/v1/config/desa', $filters); - if (!$data) { + if (! $data) { return collect([]); } - return collect($data)->map(fn($item) => (object) $item['attributes']); + return collect($data)->map(fn ($item) => (object) $item['attributes']); }); } @@ -26,7 +27,7 @@ public function desaConfig(array $filters = []) { // Panggil API dan ambil data $data = $this->apiRequest('/api/v1/config/desa', $filters); - if (!$data) { + if (! $data) { return collect([]); } @@ -44,30 +45,31 @@ public function kecamatan(array $filters = []) return Cache::remember($cacheKey, $this->cacheTtl, function () use ($filters) { $data = $this->apiRequest('/api/v1/config/kecamatan', $filters); - if (!$data) { + if (! $data) { return collect([]); } - return collect($data)->map(fn($item) => (object) $item['attributes']); + return collect($data)->map(fn ($item) => (object) $item['attributes']); }); } public function kabupaten(array $filters = []) { if (empty($filters)) { - $identitas = \App\Models\Identitas::first(); + $identitas = Identitas::first(); $kode_kabupaten = str_replace('.', '', $identitas->kode_kabupaten); $filters['filter[kode_kabupaten]'] = $kode_kabupaten; } $cacheKey = $this->buildCacheKey('config_kabupaten', $filters); + return Cache::remember($cacheKey, $this->cacheTtl, function () use ($filters) { $data = $this->apiRequest('/api/v1/config/kabupaten', $filters); - if (!$data) { + if (! $data) { return collect([]); } - return collect($data)->map(fn($item) => (object) $item['attributes']); + return collect($data)->map(fn ($item) => (object) $item['attributes']); }); } diff --git a/app/Services/DataSpasialApiService.php b/app/Services/DataSpasialApiService.php new file mode 100644 index 00000000..41ba9867 --- /dev/null +++ b/app/Services/DataSpasialApiService.php @@ -0,0 +1,589 @@ +baseUrl() !== '' && $this->resolveToken() !== ''; + } + + /** + * URL dasar API Satu Data — mengikuti pengaturan database gabungan (R1). + */ + public function baseUrl(): string + { + return rtrim((string) config('app.databaseGabunganUrl', ''), '/'); + } + + /** + * Token dari Setting pengaturan database gabungan (R1). + * + * Tidak ada konfigurasi/env khusus DataSpasial: kredensial yang sama dipakai + * oleh service database gabungan lainnya. + */ + public function resolveToken(): string + { + return (string) (Setting::where('key', 'database_gabungan_api_key')->first()?->value ?? ''); + } + + public function timeout(): int + { + $timeout = (int) config('dataspasial.timeout', 10); + + return $timeout > 0 ? $timeout : 10; + } + + public function cacheTtl(): int + { + return max(0, (int) config('dataspasial.cache_ttl', 300)); + } + + /** + * Header identitas pemanggil agar tercatat pada audit API (API-07 / #473). + * + * Header selalu dikirim; bila tidak ada konteks pengguna keduanya kosong + * sehingga sisi API dapat membedakan "klien tanpa konteks" dari + * "header tidak dikirim" (R7). + * + * @return array + */ + public function userHeaders(): array + { + $user = Auth::user(); + + return [ + (string) config('dataspasial.user_id_header', 'X-OpenKab-User-Id') => (string) ($user?->id ?? ''), + (string) config('dataspasial.user_name_header', 'X-OpenKab-User-Name') => (string) ($user?->name ?? ''), + ]; + } + + /** + * Path absolut endpoint DataSpasial relatif terhadap base URL. + */ + public function endpoint(string $path): string + { + $prefix = rtrim((string) config('dataspasial.path_prefix', '/api/v1/dataspasial'), '/'); + + return $prefix.'/'.ltrim($path, '/'); + } + + /** + * Kunci cache untuk operasi baca pada path + query tertentu. + * + * Memuat nomor generasi cache agar invalidasi menjadi O(1) tanpa perlu + * menyisir kunci satu per satu (lihat `invalidateCache()`). + * + * @param array $query + */ + public function cacheKey(string $path, array $query = []): string + { + ksort($query); + + return (string) config('dataspasial.cache_prefix', 'dataspasial:get:') + .$this->cacheGeneration() + .':'.ltrim($path, '/') + .':'.md5($this->normalizeQuery($query)); + } + + /** + * Nomor generasi cache saat ini. + */ + public function cacheGeneration(): int + { + return (int) ($this->cacheStore()->get(self::GENERATION_KEY) ?? 0); + } + + public function isAvailable(DataSpasialResult $result): bool + { + return ! $result->isUnavailable(); + } + + /** + * @param array $query + * @param array|null $body + */ + protected function request( + string $method, + string $path, + array $query = [], + ?array $body = null, + ?int $schemaVersion = null + ): DataSpasialResult { + $path = $this->endpoint($path); + $isRead = strtoupper($method) === 'GET'; + + if (! $this->isConfigured()) { + Log::warning('DataSpasial: konfigurasi tidak lengkap', [ + 'endpoint' => $path, + 'method' => strtoupper($method), + 'has_base_url' => $this->baseUrl() !== '', + ]); + + return DataSpasialResult::unavailable(DataSpasialReason::fromValue(DataSpasialReason::NotConfigured)); + } + + $cacheKey = $this->cacheKey($path, $query); + + if ($isRead && $this->cacheTtl() > 0) { + $cached = $this->cacheStore()->get($cacheKey); + + if (is_array($cached) && isset($cached['status'])) { + return DataSpasialResult::fromCachePayload($cached); + } + } + + try { + $response = $this->httpClient($schemaVersion) + ->send(strtoupper($method), $this->baseUrl().$path, array_filter([ + 'query' => $query ?: null, + 'json' => $body, + ], static fn ($value) => $value !== null)); + } catch (DataSpasialUnavailableException $exception) { + return DataSpasialResult::unavailable( + $exception->reason, + $exception->httpStatus, + $exception->getMessage() + ); + } catch (ConnectionException $exception) { + return $this->logAndReturnUnavailable( + DataSpasialReason::fromValue( + $this->isTimeout($exception) ? DataSpasialReason::Timeout : DataSpasialReason::ConnectionFailed + ), + $path, + $method, + 0, + $exception->getMessage() + ); + } catch (\Throwable $exception) { + return $this->logAndReturnUnavailable( + DataSpasialReason::fromValue(DataSpasialReason::ConnectionFailed), + $path, + $method, + 0, + $exception->getMessage() + ); + } + + $result = $this->toResult($response, $path); + + if ($isRead) { + // Hanya hasil sukses yang di-cache; `unavailable` tidak pernah + // dibekukan agar outage tidak bertahan selama TTL (R8). + if (! $result->isUnavailable()) { + $this->remember($cacheKey, $result); + } + } elseif (! $result->isUnavailable()) { + $this->invalidateCache(); + } + + return $result; + } + + /** + * Bangun `PendingRequest` lengkap dengan token, timeout, dan header audit. + */ + protected function httpClient(?int $schemaVersion = null): PendingRequest + { + $client = Http::withHeaders(array_merge([ + 'Accept' => 'application/json', + 'Authorization' => 'Bearer '.$this->resolveToken(), + ], $this->userHeaders())) + ->timeout($this->timeout()); + + $client = $client->withHeader('Content-Type', 'application/json'); + + if ($schemaVersion !== null) { + $client = $client->withHeader('X-Schema-Version', (string) $schemaVersion); + } + + return $client; + } + + /** + * Map respons HTTP menjadi `DataSpasialResult` sesuai kontrak §4.1. + */ + protected function toResult(Response $response, string $path): DataSpasialResult + { + $status = $response->status(); + $payload = $response->json(); + + if ($response->successful()) { + return $this->fromSuccessfulPayload($payload, $status); + } + + $message = is_array($payload) ? ($payload['message'] ?? $response->body()) : $response->body(); + $errorCode = is_array($payload) ? ($payload['error_code'] ?? null) : null; + + $reason = DataSpasialReason::fromValue(match (true) { + $status === 503 => DataSpasialReason::ModuleNotInstalled, + $status >= 500 => DataSpasialReason::ServiceUnavailable, + $status === 400 && $errorCode === 'SchemaVersionUnsupported' => DataSpasialReason::SchemaVersionUnsupported, + in_array($status, [401, 403], true) => DataSpasialReason::Unauthorized, + default => DataSpasialReason::ClientError, + }); + + Log::warning('DataSpasial: permintaan gagal', array_filter([ + 'endpoint' => $path, + 'http_status' => $status, + 'reason' => $reason->value, + 'error_code' => $errorCode, + 'message' => $message, + ], static fn ($value) => $value !== null)); + + return DataSpasialResult::unavailable( + $reason, + $status, + is_string($message) ? $message : null + ); + } + + protected function fromSuccessfulPayload(mixed $payload, int $status): DataSpasialResult + { + $items = $this->extractItems($payload); + $meta = $this->extractMeta($payload); + + if ($items === []) { + return DataSpasialResult::empty($meta, null, $status); + } + + return DataSpasialResult::data($items, $meta, null, $status); + } + + /** + * Ambil daftar item dari envelope Fractal/JSON:API maupun payload polos. + * + * @return array> + */ + protected function extractItems(mixed $payload): array + { + if (! is_array($payload)) { + return []; + } + + // GeoJSON dan agregat (ringkasan) tidak memakai envelope data[]. + if (array_key_exists('type', $payload) && $payload['type'] === 'FeatureCollection') { + return [$payload]; + } + + $items = $payload['data'] ?? null; + + if ($items === null) { + return isset($payload['data']) && is_array($payload['data']) ? [] : [$payload]; + } + + // `data` bisa berupa objek tunggal (mis. hasil impor) atau daftar item. + if (! array_is_list($items)) { + return [$items]; + } + + return array_values(array_filter($items, 'is_array')); + } + + /** + * @return array + */ + protected function extractMeta(mixed $payload): array + { + if (! is_array($payload)) { + return []; + } + + return array_filter([ + 'pagination' => is_array($payload['meta']['pagination'] ?? null) ? $payload['meta']['pagination'] : null, + 'links' => is_array($payload['links'] ?? null) ? $payload['links'] : null, + ], static fn ($value) => $value !== null); + } + + /** + * Simpan hasil baca yang sukses. Respons `unavailable` tidak pernah + * di-cache agar outage tidak "membeku" selama TTL (R8). + */ + protected function remember(string $cacheKey, DataSpasialResult $result): void + { + if ($this->cacheTtl() <= 0) { + return; + } + + $this->cacheStore()->put($cacheKey, $result->toCachePayload(), $this->cacheTtl()); + } + + /** + * Buang seluruh cache baca DataSpasial. + * + * Dilakukan dengan menaikkan nomor generasi cache sehingga seluruh kunci + * baca lama langsung tidak terjangkau, alih-alih menyisir kunci satu + * per satu. Invalidasi sengaja kasar per-namespace: menulis skema + * memengaruhi semua baca (nilai, adopsi, ringkasan), sehingga invalidasi + * per-endpoint rawan menampilkan data basi (FR-006/SC-008). + * + * Kunci lama tetap tersimpan sampai TTL-nya habis, namun tidak akan + * pernah dibaca lagi. + */ + public function invalidateCache(): void + { + $store = $this->cacheStore(); + $next = $this->cacheGeneration() + 1; + + $store->forever(self::GENERATION_KEY, $next); + + Log::debug('DataSpasial: cache baca diinvalidasi', [ + 'prefix' => (string) config('dataspasial.cache_prefix', 'dataspasial:get:'), + 'generation' => $next, + ]); + } + + protected function cacheStore(): Repository + { + $store = config('dataspasial.cache_store'); + + return Cache::store($store ?: null); + } + + protected function logAndReturnUnavailable( + DataSpasialReason $reason, + string $path, + string $method, + int $status, + ?string $message + ): DataSpasialResult { + Log::warning('DataSpasial: permintaan tidak dapat dikirim', array_filter([ + 'endpoint' => $path, + 'method' => strtoupper($method), + 'http_status' => $status, + 'reason' => $reason->value, + 'message' => $message, + ], static fn ($value) => $value !== null)); + + return DataSpasialResult::unavailable($reason, $status, $message); + } + + protected function isTimeout(ConnectionException $exception): bool + { + return str_contains(strtolower($exception->getMessage()), 'timed out') + || str_contains(strtolower($exception->getMessage()), 'timeout'); + } + + /** + * @param array $query + */ + protected function normalizeQuery(array $query): string + { + return (string) json_encode($query); + } + + // ----------------------------------------------------------------------- + // Operasi baca (FR-002) + // ----------------------------------------------------------------------- + + /** + * Daftar sektor DataSpasial. + * + * @param array $filters + */ + public function listSektor(array $filters = []): DataSpasialResult + { + return $this->request('GET', 'sektor', $filters); + } + + /** + * Daftar versi skema untuk SATU sektor. + * + * ⚠️ VERIFIKASI LANGSUNG KE API NYATA (spec Q8 / kontrak §6a.4 K1): + * parameter bernama **`sektor`** dan **WAJIB** — `GET /dataspasial/skema` + * tanpa parameter itu membalas `422 {"errors":{"sektor":[...]}}`. + * Nilai yang diterima berupa id numerik (`4234`) maupun kode (`SK0`). + * Parameter `sektor_id` yang tertera pada draft `openapi-fondasi.yaml` + * **tidak dikenali** oleh API dan menghasilkan 422. + * + * ⚠️ (K2) Respons sudah memuat `attributes.isian[]` LENGKAP dengan + * `pemetaan[]`, jadi TIDAK ada endpoint detail terpisah. + * + * @param array $filters Wajib menyertakan `sektor`. + */ + public function listSkema(array $filters = []): DataSpasialResult + { + return $this->request('GET', 'skema', $filters); + } + + /** + * Daftar **seluruh versi** skema milik satu sektor (riwayat versi). + * + * ⚠️ VERIFIKASI LANGSUNG + pembacaan kode API (spec Q9): endpoint + * `GET /dataspasial/skema?sektor={id}` **hanya mengembalikan SATU versi** + * (yang sedang aktif), sehingga tidak dapat dipakai sebagai sumber daftar + * skema. Endpoint ini — `GET /dataspasial/skema/riwayat?sektor={id}` — + * mengembalikan seluruh versi sektor tersebut (nomor versi, status, catatan + * perubahan, waktu terbit). + * + * Parameternya berupa **kode sektor** (bukan id), sama seperti + * `sektor()` pada API. + * + * @param array $filters Wajib menyertakan `sektor`. + */ + public function riwayatSkema(array $filters = []): DataSpasialResult + { + return $this->request('GET', 'skema/riwayat', $filters); + } + + /** + * Detail satu versi skema beserta isiannya. + * + * @deprecated Endpoint `GET /dataspasial/skema/{id}` **tidak ada** di API + * nyata (mengembalikan 404). Gunakan `listSkema(['sektor' => …])` + * yang sudah menyertakan isian. Dipertahankan sementara agar + * pemanggilan lama tidak langsung rusak saat transisi. + */ + public function detailSkema(int $id): DataSpasialResult + { + Log::warning('DataSpasial: detailSkema() dipanggil padahal endpoint detail tidak ada', [ + 'skema_id' => $id, + ]); + + return $this->request('GET', 'skema/'.$id); + } + + /** + * Daftar isian skema. + * + * @param array $filters + */ + public function listIsian(array $filters = []): DataSpasialResult + { + return $this->request('GET', 'isian', $filters); + } + + /** + * Daftar pemetaan skema ke sasaran wilayah. + * + * @param array $filters + */ + public function listPemetaan(array $filters = []): DataSpasialResult + { + return $this->request('GET', 'pemetaan', $filters); + } + + /** + * Daftar fasilitas. + * + * @param array $filters + */ + public function listFasilitas(array $filters = []): DataSpasialResult + { + return $this->request('GET', 'fasilitas', $filters); + } + + /** + * Daftar nilai isian. + * + * @param array $filters + */ + public function listNilai(array $filters = []): DataSpasialResult + { + return $this->request('GET', 'nilai', $filters); + } + + /** + * Ringkasan agregat untuk dasbor. + * + * @param array $filters + */ + public function ringkasan(array $filters = []): DataSpasialResult + { + return $this->request('GET', 'ringkasan', $filters); + } + + /** + * FeatureCollection GeoJSON gabungan untuk Peta Gabungan. + * + * @param array $filters + */ + public function geojsonGabungan(array $filters = []): DataSpasialResult + { + return $this->request('GET', 'geojson-gabungan', $filters); + } + + /** + * Status adopsi skema per desa. + * + * @param array $filters + */ + public function adopsi(array $filters = []): DataSpasialResult + { + return $this->request('GET', 'adopsi', $filters); + } + + // ----------------------------------------------------------------------- + // Operasi tulis + // ----------------------------------------------------------------------- + + /** + * Buat atau perbarui draf skema beserta isiannya. + * + * @param array $body + */ + public function buatDrafSkema(array $body, ?int $schemaVersion = null): DataSpasialResult + { + return $this->request('POST', 'skema', [], $body, $schemaVersion); + } + + /** + * Terbitkan draf skema menjadi versi `terbit` yang immutable. + * + * @param array $body + */ + public function terbitkanSkema(string $sektor, array $body, ?int $schemaVersion = null): DataSpasialResult + { + return $this->request( + 'POST', + 'skema/'.rawurlencode($sektor).'/terbit', + [], + $body, + $schemaVersion + ); + } + + /** + * Impor nilai isian dalam jumlah besar untuk satu desa dan periode. + * + * @param array $body + */ + public function imporNilai(array $body, ?int $schemaVersion = null): DataSpasialResult + { + return $this->request('POST', 'impor', [], $body, $schemaVersion); + } +} diff --git a/app/Services/DataSpasialResult.php b/app/Services/DataSpasialResult.php new file mode 100644 index 00000000..e61b77c7 --- /dev/null +++ b/app/Services/DataSpasialResult.php @@ -0,0 +1,204 @@ +> $data Item hasil unwrap dari envelope API + * @param array $meta Berisi kunci `pagination` dan `links` bila ada + */ + private function __construct( + public readonly DataSpasialStatus $status, + public readonly array $data, + public readonly array $meta, + public readonly ?DataSpasialReason $reason, + public readonly int $httpStatus, + public readonly ?string $message, + ) { + $this->assertInvariants(); + } + + /** + * Respons sukses berisi minimal satu item. + * + * @param array> $data + * @param array $meta + */ + public static function data(array $data, array $meta = [], ?DataSpasialReason $reason = null, int $httpStatus = 200): self + { + return new self(DataSpasialStatus::fromValue(DataSpasialStatus::Data), $data, $meta, $reason, $httpStatus, null); + } + + /** + * Respons sukses tanpa data (FR-015). + * + * @param array $meta + */ + public static function empty(array $meta = [], ?DataSpasialReason $reason = null, int $httpStatus = 200): self + { + return new self(DataSpasialStatus::fromValue(DataSpasialStatus::Empty), [], $meta, $reason, $httpStatus, null); + } + + /** + * Data tidak dapat diperoleh sementara (FR-014). + * + * @param array $meta + */ + public static function unavailable( + DataSpasialReason $reason, + int $httpStatus = 0, + ?string $message = null, + array $meta = [] + ): self { + return new self(DataSpasialStatus::fromValue(DataSpasialStatus::Unavailable), [], $meta, $reason, $httpStatus, $message); + } + + public function isData(): bool + { + return $this->status->is(DataSpasialStatus::Data); + } + + public function isEmpty(): bool + { + return $this->status->is(DataSpasialStatus::Empty); + } + + public function isUnavailable(): bool + { + return $this->status->is(DataSpasialStatus::Unavailable); + } + + /** + * @return array|null + */ + public function first(): ?array + { + return $this->data[0] ?? null; + } + + public function count(): int + { + return count($this->data); + } + + /** + * @return array + */ + public function pagination(): array + { + return $this->meta['pagination'] ?? []; + } + + /** + * @return array + */ + public function links(): array + { + return $this->meta['links'] ?? []; + } + + /** + * Pesan generik untuk pengguna akhir. Tidak pernah menyertakan pesan + * teknis dari API (FR-014/FR-016). + */ + public function userMessage(): string + { + return DataSpasialReason::userMessage(); + } + + /** + * Bentuk serializable untuk disimpan pada cache. + * + * @return array + */ + public function toCachePayload(): array + { + return [ + 'status' => $this->status->value, + 'data' => $this->data, + 'meta' => $this->meta, + 'http_status' => $this->httpStatus, + ]; + } + + /** + * Bangun kembali hasil dari cache. `reason` & `message` sengaja tidak + * disimpan karena hasil `unavailable` tidak pernah masuk cache. + * + * @param array $payload + */ + public static function fromCachePayload(array $payload): self + { + $data = is_array($payload['data'] ?? null) ? $payload['data'] : []; + $meta = is_array($payload['meta'] ?? null) ? $payload['meta'] : []; + $httpStatus = (int) ($payload['http_status'] ?? 200); + + return $data === [] + ? self::empty($meta, null, $httpStatus) + : self::data($data, $meta, null, $httpStatus); + } + + /** + * Konteks teknis untuk log backend. WAJIB tidak dirender ke view. + * + * @return array + */ + public function logContext(string $endpoint = ''): array + { + return array_filter([ + 'endpoint' => $endpoint !== '' ? $endpoint : null, + 'status' => $this->status->value, + 'reason' => $this->reason?->value, + 'http_status' => $this->httpStatus, + 'message' => $this->message, + ], static fn ($value) => $value !== null); + } + + /** + * Jaga invariant object agar tidak dapat dibangun salah oleh pemanggil. + */ + private function assertInvariants(): void + { + if ($this->status->is(DataSpasialStatus::Data)) { + if ($this->data === []) { + throw new InvalidArgumentException( + 'DataSpasialResult berstatus data wajib memiliki minimal satu item.' + ); + } + + if ($this->reason !== null) { + throw new InvalidArgumentException( + 'DataSpasialResult berstatus data tidak boleh memiliki reason.' + ); + } + } + + if ($this->status->is(DataSpasialStatus::Empty) && $this->reason !== null) { + throw new InvalidArgumentException( + 'DataSpasialResult berstatus empty tidak boleh memiliki reason.' + ); + } + + if ($this->status->is(DataSpasialStatus::Unavailable) && $this->reason === null) { + throw new InvalidArgumentException( + 'DataSpasialResult berstatus unavailable wajib memiliki reason.' + ); + } + } +} diff --git a/app/Services/IlluminatePaginatorAdapter.php b/app/Services/IlluminatePaginatorAdapter.php index 705ac6d3..ca399e45 100644 --- a/app/Services/IlluminatePaginatorAdapter.php +++ b/app/Services/IlluminatePaginatorAdapter.php @@ -7,9 +7,7 @@ class IlluminatePaginatorAdapter implements PaginatorInterface { - public function __construct(protected LengthAwarePaginator $paginator) - { - } + public function __construct(protected LengthAwarePaginator $paginator) {} /** * {@inheritdoc} diff --git a/app/Services/OtpService.php b/app/Services/OtpService.php index 2d0bbda0..0c18b685 100644 --- a/app/Services/OtpService.php +++ b/app/Services/OtpService.php @@ -2,12 +2,13 @@ namespace App\Services; +use App\Mail\OtpMail; use App\Models\OtpToken; use App\Models\User; use Illuminate\Support\Facades\Hash; -use Illuminate\Support\Facades\Mail; -use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Http; +use Illuminate\Support\Facades\Log; +use Illuminate\Support\Facades\Mail; class OtpService { @@ -21,111 +22,115 @@ public function generateAndSend($userId, $channel, $identifier) $otp = random_int($min, $max); $hash = Hash::make($otp); $expires = now()->addMinutes(config('app.otp_token_expires_minutes', 5)); - + // Delete any existing tokens for this user OtpToken::where('user_id', $userId)->delete(); - + // Create new token OtpToken::create([ - 'user_id' => $userId, - 'token_hash' => $hash, + 'user_id' => $userId, + 'token_hash' => $hash, 'channel' => $channel, - 'identifier' => $identifier, + 'identifier' => $identifier, 'expires_at' => $expires, - 'attempts' => 0 + 'attempts' => 0, ]); - + // Send OTP based on channel if ($channel === 'email') { - Mail::to($identifier)->send(new \App\Mail\OtpMail($otp)); + Mail::to($identifier)->send(new OtpMail($otp)); Log::info('OTP Sent via Email', ['user_id' => $userId, 'email' => $identifier]); - } else if ($channel === 'telegram') { + } elseif ($channel === 'telegram') { $this->sendTelegramOtp($identifier, $otp); Log::info('OTP Sent via Telegram', ['user_id' => $userId, 'chat_id' => $identifier]); } - + return [ 'success' => true, 'message' => 'Kode Token berhasil dikirim', - 'channel' => $channel + 'channel' => $channel, ]; - + } catch (\Exception $e) { Log::error('OTP Generation Failed', ['user_id' => $userId, 'error' => $e->getMessage()]); + return [ 'success' => false, - 'message' => 'Gagal mengirim OTP: ' . $e->getMessage() + 'message' => 'Gagal mengirim OTP: '.$e->getMessage(), ]; } } - + private function sendTelegramOtp($chatId, $otp) { $botToken = env('TELEGRAM_BOT_TOKEN'); - if (!$botToken) { + if (! $botToken) { throw new \Exception('Telegram bot token tidak dikonfigurasi'); } - + $expiresMinutes = config('app.otp_token_expires_minutes', 5); $message = "🔐 *Kode Token OpenKab*\n\n"; $message .= "Kode verifikasi Anda: *{$otp}*\n\n"; $message .= "Kode berlaku selama {$expiresMinutes} menit.\n"; - $message .= "Jangan bagikan kode ini kepada siapa pun!"; - + $message .= 'Jangan bagikan kode ini kepada siapa pun!'; + $response = Http::post("https://api.telegram.org/bot{$botToken}/sendMessage", [ 'chat_id' => $chatId, 'text' => $message, - 'parse_mode' => 'Markdown' + 'parse_mode' => 'Markdown', ]); - - if (!$response->successful()) { + + if (! $response->successful()) { throw new \Exception('Gagal mengirim pesan Telegram'); } } - + public function verify($userId, $submittedOtp) { $token = OtpToken::where('user_id', $userId) ->where('expires_at', '>', now()) ->first(); - - if (!$token) { + + if (! $token) { return [ 'success' => false, - 'message' => 'Token tidak ditemukan atau sudah kedaluwarsa' + 'message' => 'Token tidak ditemukan atau sudah kedaluwarsa', ]; } - + $maxAttempts = config('app.otp_max_verification_attempts', 3); if ($token->attempts >= $maxAttempts) { return [ 'success' => false, - 'message' => 'Terlalu banyak percobaan salah. Silakan minta kode baru.' + 'message' => 'Terlalu banyak percobaan salah. Silakan minta kode baru.', ]; } - + if (Hash::check($submittedOtp, $token->token_hash)) { $token->delete(); Log::info('OTP Verified Successfully', ['user_id' => $userId]); + return [ 'success' => true, - 'message' => 'Kode Token berhasil diverifikasi' + 'message' => 'Kode Token berhasil diverifikasi', ]; } else { $token->increment('attempts'); $maxAttempts = config('app.otp_max_verification_attempts', 3); Log::warning('OTP Verification Failed', ['user_id' => $userId, 'attempts' => $token->attempts + 1]); + return [ 'success' => false, - 'message' => 'Kode Token salah. Percobaan tersisa: ' . ($maxAttempts - ($token->attempts + 1)) + 'message' => 'Kode Token salah. Percobaan tersisa: '.($maxAttempts - ($token->attempts + 1)), ]; } } - + public function cleanupExpired() { $deleted = OtpToken::where('expires_at', '<', now())->delete(); Log::info('Expired OTP tokens cleaned up', ['deleted' => $deleted]); + return $deleted; } -} \ No newline at end of file +} diff --git a/app/Services/SitemapService.php b/app/Services/SitemapService.php index c6f256bd..728d9064 100644 --- a/app/Services/SitemapService.php +++ b/app/Services/SitemapService.php @@ -4,6 +4,8 @@ use App\Models\CMS\Category; use App\Models\CMS\Page; +use Illuminate\Contracts\Routing\ResponseFactory; +use Illuminate\Http\Response; class SitemapService { @@ -42,7 +44,7 @@ public function render() } /** - * @param string $set + * @param string $set */ private function init($set = 'urlset'): string { @@ -53,9 +55,8 @@ private function init($set = 'urlset'): string } /** - * @param string $set - * - * @return \Illuminate\Contracts\Routing\ResponseFactory|\Illuminate\Http\Response + * @param string $set + * @return ResponseFactory|Response */ private function end($set = 'urlset') { @@ -65,8 +66,8 @@ private function end($set = 'urlset') } /** - * @param string $loc - * @param string $lastmod + * @param string $loc + * @param string $lastmod */ private function addUrl($loc, $lastmod = null): string { diff --git a/app/Services/SkemaEditorService.php b/app/Services/SkemaEditorService.php new file mode 100644 index 00000000..63bb31d6 --- /dev/null +++ b/app/Services/SkemaEditorService.php @@ -0,0 +1,477 @@ + + */ + private const PENANDA_TAMPILAN = [ + 'tampil_popup' => 'popupPeta', + 'tampil_filter' => 'sebagaiFilter', + 'tampil_indikator' => 'sebagaiIndikator', + ]; + + // ----------------------------------------------------------------------- + // Read model + // ----------------------------------------------------------------------- + + /** + * Petakan satu item `ds_sektor` menjadi bentuk view. + * + * @param array $item + * @return array + */ + public function petakanSektor(array $item): array + { + $attributes = $this->attributes($item); + + return [ + 'id' => (int) ($item['id'] ?? $attributes['id'] ?? 0), + 'kode' => (string) ($attributes['kode'] ?? ''), + 'nama' => (string) ($attributes['nama'] ?? ''), + 'ikon' => $attributes['ikon'] ?? null, + 'status' => (string) ($attributes['status'] ?? 'aktif'), + // Counter dari API nyata (§6a.2). Karena Q22 melarang memanggil + // endpoint skema per sektor (SC-013: tepat satu pemanggilan baca), + // versi terakhir dan statusnya DITURUNKAN dari kedua counter ini: + // jumlah_versi_skema = 0 → belum ada skema + // jumlah_versi_terbit < jumlah_skema → versi terakhir berstatus draf + // selainnya → versi terakhir berstatus terbit + // Penomoran versi dibuat berurutan oleh API (Q18), sehingga nomor + // versi terakhir sama dengan jumlah versi yang ada. + 'jumlahSkema' => (int) ($attributes['jumlah_versi_skema'] ?? 0), + 'jumlahTerbit' => (int) ($attributes['jumlah_versi_terbit'] ?? 0), + 'adaDraf' => ((int) ($attributes['jumlah_versi_skema'] ?? 0)) > 0 + && ((int) ($attributes['jumlah_versi_skema'] ?? 0)) > ((int) ($attributes['jumlah_versi_terbit'] ?? 0)), + 'nomorVersiTerakhir' => (int) ($attributes['jumlah_versi_skema'] ?? 0), + 'statusTerakhir' => $this->statusTerakhirSektor( + (int) ($attributes['jumlah_versi_skema'] ?? 0), + (int) ($attributes['jumlah_versi_terbit'] ?? 0), + ), + ]; + } + + /** + * Turunkan status versi terakhir sebuah sektor dari counter API (Q22). + * + * @return string `draf`, `terbit`, atau `kosong` + */ + private function statusTerakhirSektor(int $jumlahVersi, int $jumlahTerbit): string + { + if ($jumlahVersi === 0) { + return 'kosong'; + } + + return $jumlahTerbit < $jumlahVersi ? 'draf' : 'terbit'; + } + + /** + * Petakan daftar item sektor dan hitung berapa skema/draf tiap sektor (R1). + * + * @param array> $items + * @return array> + */ + public function petakanDaftarSektor(array $items): array + { + return array_map(fn (array $item): array => $this->petakanSektor($item), $items); + } + + /** + * Petakan satu item `ds_skema_versi` menjadi bentuk view. + * + * @param array $item + * @return array + */ + public function petakanSektorSkema(array $item): array + { + $attributes = $this->attributes($item); + $sektor = is_array($attributes['sektor'] ?? null) ? $attributes['sektor'] : []; + + // ⚠️ VERIFIKASI LANGSUNG (K3): versi & status berada di + // `attributes.versi_skema`, BUKAN di root `attributes`. + // Fallback ke root dipertahankan agar draft `openapi-fondasi.yaml` + // masih terbaca bila API belum diperbarui. + $versi = is_array($attributes['versi_skema'] ?? null) ? $attributes['versi_skema'] : []; + + $id = (int) ($versi['id'] ?? $item['id'] ?? 0); + $sektorId = (int) ($sektor['id'] ?? $versi['sektor_id'] ?? $attributes['sektor_id'] ?? 0); + + return [ + 'id' => $id, + 'sektorId' => $sektorId, + 'sektorKode' => (string) ($sektor['kode'] ?? ''), + 'sektorNama' => (string) ($sektor['nama'] ?? ''), + 'nomorVersi' => (int) ($versi['nomor_versi'] ?? $attributes['nomor_versi'] ?? 0), + 'status' => (string) ($versi['status'] ?? $attributes['status'] ?? ''), + 'diterbitkanPada' => $versi['diterbitkan_pada'] ?? $attributes['diterbitkan_pada'] ?? null, + 'catatanPerubahan' => $versi['catatan_perubahan'] ?? $attributes['catatan_perubahan'] ?? null, + 'jumlahIsian' => count(is_array($attributes['isian'] ?? null) ? $attributes['isian'] : []), + ]; + } + + /** + * Petakan satu item `ds_isian` menjadi bentuk view dengan NAMA INTERNAL. + * + * @param array $item + * @return array + */ + public function petakanIsian(array $item): array + { + $attributes = $this->attributes($item); + $pemetaan = $this->pemetaanPertama(is_array($attributes['pemetaan'] ?? null) ? $attributes['pemetaan'] : []) ?? []; + + // `rujukan_sumber` dipecah menjadi tabel + kolom untuk ditampilkan. + // Rujukan kosong dinormalkan jadi `null` (bukan string kosong) supaya + // pemetaan yang belum punya rujukan tetap terbaca sebagai "tidak ada". + $rujukan = trim((string) ($pemetaan['rujukan'] ?? '')); + [$tabelSumber, $kolomSumber] = $this->pecahRujukan($rujukan); + + $isian = [ + 'id' => isset($item['id']) ? (int) $item['id'] : (isset($attributes['id']) ? (int) $attributes['id'] : null), + 'kode' => (string) ($attributes['kode'] ?? ''), + 'label' => (string) ($attributes['label'] ?? ''), + 'tipe' => (string) ($attributes['tipe'] ?? 'teks'), + 'wajib' => (bool) ($attributes['wajib'] ?? false), + 'satuan' => $attributes['satuan'] ?? null, + 'opsi' => array_values(array_filter( + is_array($attributes['opsi'] ?? null) ? $attributes['opsi'] : [], + static fn ($opsi): bool => is_string($opsi) && $opsi !== '', + )), + 'urutan' => (int) ($attributes['urutan'] ?? 0), + 'diarsipkan' => (bool) ($attributes['diarsipkan'] ?? false), + // ⚠️ VERIFIKASI LANGSUNG: `sumber` adalah ENUM API + // (`openSID`/`dataPresisi`/`impor`), BUKAN kode sektor (§6a.5). + 'sumber' => $pemetaan['sumber'] ?? 'manual', + // ⚠️ VERIFIKASI LANGSUNG (§6a.4): entitas `ds_pemetaan` hanya + // menyimpan SATU kolom `rujukan_sumber` berbentuk "tabel.kolom" + // (contoh `tbl_jalan.lebar`). Karena form menampilkan dua kolom, + // nilainya dipecah di sini; saat menyimpan, keduanya disambung + // kembali menjadi `rujukan_sumber` oleh `composePemetaan()`. + 'rujukan' => $rujukan === '' ? null : $rujukan, + 'tabelSumber' => $tabelSumber, + 'kolomSumber' => $kolomSumber, + 'aturanHitung' => $pemetaan['aturan_hitung'] ?? null, + ]; + + // Penanda tampilan: nama RESMI dibaca dengan default `false`. + foreach (self::PENANDA_TAMPILAN as $api => $internal) { + $isian[$internal] = (bool) ($attributes[$api] ?? false); + } + + return $isian; + } + + /** + * Petakan daftar isian dan urutkan berdasarkan `urutan` (FR-012). + * + * @param array> $items + * @return array> + */ + public function petakanDaftarIsian(array $items): array + { + $isian = array_map(fn (array $item): array => $this->petakanIsian($item), $items); + + usort($isian, static fn (array $a, array $b): int => $a['urutan'] <=> $b['urutan']); + + return $isian; + } + + /** + * Petakan daftar `ds_pemetaan` lalu pasang ke isian terkait (FR-023/FR-024). + * + * @param array> $items + * @return array> + */ + public function petakanPemetaan(array $items): array + { + return array_map(function (array $item): array { + $attributes = $this->attributes($item); + + return [ + 'id' => (int) ($item['id'] ?? 0), + 'sumber' => $attributes['sumber'] ?? null, + 'rujukan' => $attributes['rujukan_sumber'] ?? null, + 'aturan_hitung' => $attributes['aturan_hitung'] ?? null, + ]; + }, $items); + } + + /** + * Pemetaan pertama sebuah isian (API mengembalikan array; isian memakai satu). + * + * @param array $items + * @return array|null + */ + private function pemetaanPertama(array $items): ?array + { + foreach ($items as $item) { + if (is_array($item)) { + return $this->petakanPemetaan([$item])[0] ?? null; + } + } + + return null; + } + + /** + * Tempelkan status sumber nilai pada tiap isian (FR-023). + * + * @param array> $isian + * @param array> $pemetaan + * @return array> + */ + public function terapkanPemetaan(array $isian, array $pemetaan): array + { + $perIsian = []; + + foreach ($pemetaan as $peta) { + if (($peta['sumber'] ?? null) === null || ($peta['sumber'] ?? '') === '') { + continue; + } + + $perIsian[$peta['isianId'] ?? 0] = $peta; + } + + return array_map(function (array $baris) use ($perIsian): array { + $peta = $perIsian[$baris['id']] ?? null; + + if ($peta === null) { + return $baris; + } + + return array_merge($baris, [ + 'sumber' => $peta['sumber'], + 'rujukan' => $peta['rujukan'], + 'aturanHitung' => $peta['aturan_hitung'] ?? null, + ]); + }, $isian); + } + + /** + * Turunkan `StatusSkemaEditor` dari skema yang dipilih (FR-008a/FR-008b). + * + * @param array|null $skemaTerpilih + * @param array> $skemaSektor + */ + public function statusEditor(?array $skemaTerpilih, array $skemaSektor): StatusSkemaEditor + { + if ($skemaTerpilih !== null) { + return StatusSkemaEditor::dari([ + 'status' => $skemaTerpilih['status'], + 'jumlah_skema' => count($skemaSektor), + ]); + } + + $draf = $this->cariSkema($skemaSektor, StatusSkemaEditor::Draf); + + if ($draf !== null) { + return StatusSkemaEditor::dari(['status' => $draf['status']]); + } + + return StatusSkemaEditor::dari([ + 'status' => '', + 'jumlah_skema' => count($skemaSektor), + ]); + } + + /** + * Hasilkan kode sektor dari nama (Q5 / FR-007d-1). + * + * Pola `snake_case`: huruf kecil, angka, garis bawah; diawali huruf. + * Bila hasil kosong (mis. nama hanya simbol), fallback `sektor`. + * Keunikan global ditjamin API (Q13); OpenKab hanya menyusun bentuknya. + */ + public function kodeSektorDariNama(string $nama): string + { + $kode = Str::lower(preg_replace('/[^a-z0-9]+/i', '_', trim($nama))); + $kode = trim($kode, '_'); + + if ($kode === '' || ! preg_match('/^[a-z]/', $kode)) { + $kode = 'sektor'; + } + + return $kode; + } + + // ----------------------------------------------------------------------- + // Write model + // ----------------------------------------------------------------------- + + /** + * Susun badan draf untuk dikirim ke API (R2). + * + * ⚠️ **NAMA ASUMSI KONTRAK HANYA BOLEH MUNCUL DI SINI** (R12). + * Input memakai nama internal hasil validasi FormRequest. + * + * @param array> $isian Hasil `$request->validated()['isian']` + * @param array $meta Berisi `sektor`, `nomor_versi`, `catatan_perubahan` + * @return array + */ + public function composeDrafPayload(array $isian, array $meta = []): array + { + $urut = 1; + + $isianApi = array_map(function (array $baris) use (&$urut): array { + $payload = [ + 'kode' => $baris['kode'], + 'label' => $baris['label'], + 'tipe' => $baris['tipe'], + 'wajib' => (bool) ($baris['wajib'] ?? false), + 'satuan' => $baris['satuan'] ?? null, + 'opsi' => ($baris['tipe'] ?? '') === 'pilihan' ? array_values($baris['opsi'] ?? []) : null, + 'urutan' => $urut++, + // Nama TULIS API (Q20) — berbeda dari nama baca `tampil_popup`/ + // `tampil_filter`/`tampil_indikator`. View memakai nama internal + // (`popupPeta`, `sebagaiFilter`, `sebagaiIndikator`); terjemahan + // terjadi hanya di sini (R12 — satu tempat). + 'tampil_di_popup' => (bool) ($baris['popupPeta'] ?? false), + 'tampil_di_filter' => (bool) ($baris['sebagaiFilter'] ?? false), + 'tampil_di_indikator' => (bool) ($baris['sebagaiIndikator'] ?? false), + // Pemetaan berupa array `{sumber, rujukan_sumber}` — satu nilai + // "tabel.kolom" (§6a.4). View memakai `rujukan` sebagai sumber + // kebenaran; `composePemetaan()` yang menyusun bentuk API. + 'pemetaan' => $this->composePemetaan($baris), + ]; + + return $payload; + }, $isian); + + return array_merge([ + 'sektor' => $meta['sektor'] ?? null, + 'sektor_nama' => $meta['sektor_nama'] ?? null, + 'keterangan' => $meta['keterangan'] ?? null, + 'isian' => $isianApi, + ], []); + } + + /** + * Susun pemetaan satu isian untuk badan draf. + * + * ⚠️ Bentuk API (`ds_pemetaan`) hanya punya **`rujukan_sumber`** — satu + * string "tabel.kolom" (contoh `tbl_jalan.lebar`, §6a.4). Klien boleh + * mengirim `rujukan` apa adanya; bila hanya mengirim `tabelSumber` + + * `kolomSumber` keduanya disambung di sini. + * + * @param array $baris + * @return array|null + */ + private function composePemetaan(array $baris): ?array + { + $sumber = $baris['sumber'] ?? null; + + if ($sumber === null || $sumber === '' || $sumber === 'manual') { + return null; + } + + return [[ + 'sumber' => (string) $sumber, + 'rujukan_sumber' => $this->susunRujukan($baris), + ]]; + } + + /** + * Rujukan sumber satu isian: pakai `rujukan` bila ada, kalau tidak + * sambung `tabelSumber` + `kolomSumber` menjadi "tabel.kolom". + * + * @param array $baris + */ + private function susunRujukan(array $baris): string + { + $rujukan = trim((string) ($baris['rujukan'] ?? '')); + + if ($rujukan !== '') { + return $rujukan; + } + + $tabel = trim((string) ($baris['tabelSumber'] ?? '')); + $kolom = trim((string) ($baris['kolomSumber'] ?? '')); + + return implode('.', array_filter([$tabel, $kolom], static fn (string $bagian): bool => $bagian !== '')); + } + + /** + * Pecah `rujukan_sumber` ("tabel.kolom") menjadi bagian tabel & kolom. + * + * ⚠️ Pemecahan memakai titik TERAKHIR agar nilai yang hanya memuat satu + * titik tetap pulih utuh saat disambung kembali (mis. `ds.tbl.col`). + * + * @return array{0: string, 1: string} + */ + private function pecahRujukan(?string $rujukan): array + { + $rujukan = trim((string) $rujukan); + + if ($rujukan === '') { + return ['', '']; + } + + $titik = strrpos($rujukan, '.'); + + if ($titik === false) { + return [$rujukan, '']; + } + + return [substr($rujukan, 0, $titik), substr($rujukan, $titik + 1)]; + } + + // ----------------------------------------------------------------------- + // Helper + // ----------------------------------------------------------------------- + + /** + * @param array $item + * @return array + */ + private function attributes(array $item): array + { + return is_array($item['attributes'] ?? null) ? $item['attributes'] : $item; + } + + /** + * @param array> $daftar + * @return array|null + */ + private function cariSkema(array $daftar, string $status): ?array + { + foreach ($daftar as $skema) { + if (($skema['status'] ?? '') === $status) { + return $skema; + } + } + + return null; + } +} diff --git a/app/Services/TwoFactorService.php b/app/Services/TwoFactorService.php index c6689090..58e375e1 100644 --- a/app/Services/TwoFactorService.php +++ b/app/Services/TwoFactorService.php @@ -72,4 +72,4 @@ public function getTwoFactorIdentifier(User $user) { return $user->{'2fa_enabled'} ? $user->{'2fa_identifier'} : null; } -} \ No newline at end of file +} diff --git a/config/dataspasial.php b/config/dataspasial.php new file mode 100644 index 00000000..45b605b2 --- /dev/null +++ b/config/dataspasial.php @@ -0,0 +1,61 @@ + (int) env('API_DATASPASIAL_TIMEOUT', 10), + + /* + | Masa berlaku cache operasi baca dalam detik. 0 berarti cache nonaktif. + */ + 'cache_ttl' => (int) env('API_DATASPASIAL_CACHE_TTL', 300), + + /* + | Store cache yang dipakai. null berarti memakai default aplikasi. + */ + 'cache_store' => env('API_DATASPASIAL_CACHE_STORE'), + + /* + | Prefix kunci cache operasi baca. Memungkinkan invalidasi seluruh cache + | baca tanpa menyentuh cache modul lain. + */ + 'cache_prefix' => 'dataspasial:get:', + + /* + | Header identitas pemanggil yang dibaca API untuk audit (API-07). + */ + 'user_id_header' => 'X-OpenKab-User-Id', + + 'user_name_header' => 'X-OpenKab-User-Name', + + /* + | Prefix path endpoint DataSpasial di sisi API. + */ + 'path_prefix' => '/api/v1/dataspasial', + + /* + | Pesan yang ditampilkan ke pengguna saat data tidak dapat diperoleh. + | WAJIB generik — detail teknis hanya untuk log backend. + */ + 'unavailable_message' => 'Data sementara tidak tersedia. Silakan coba lagi nanti.', + +]; diff --git a/database/factories/UserFactory.php b/database/factories/UserFactory.php index 2a9c4e3b..b538a4bc 100644 --- a/database/factories/UserFactory.php +++ b/database/factories/UserFactory.php @@ -27,9 +27,15 @@ public function definition() // ]; return [ - 'username' => $this->faker->userName, + // ⚠️ WAJIB `unique()` + sufiks acak. Kolom `username` punya batasan unik + // di DB dan basis data pengembangan sudah berisi ratusan baris, + // sehingga `unique()` saja (yang hanya men unik dalam satu proses) + // TIDAK cukup — nama dari kumpulan faker bisa saja sudah ada. + // Sufiks 6 karakter acak membuat bentrok praktis mustahil tanpa + // mengubah bentuk nama yang dipakai test lain. + 'username' => $this->faker->unique()->userName.'.'.Str::lower(Str::random(6)), 'password' => Hash::make('password'), // default password - 'email' => $this->faker->unique()->safeEmail, + 'email' => $this->faker->unique()->safeEmail.'+'.Str::lower(Str::random(6)).'@example.test', 'last_login' => now(), 'email_verified_at' => now(), 'active' => 1, diff --git a/database/migrations/2026_10_14_000001_create_dataspasial_roles.php b/database/migrations/2026_10_14_000001_create_dataspasial_roles.php new file mode 100644 index 00000000..fb8a4207 --- /dev/null +++ b/database/migrations/2026_10_14_000001_create_dataspasial_roles.php @@ -0,0 +1,298 @@ + izin menu (research.md R6). + * + * `read` dipakai untuk akses tampilan; `write`/`edit`/`delete` hanya + * diberikan kepada peran yang memang mengelola. + * + * @var array>> + */ + private const ROLE_PERMISSIONS = [ + 'admin skema' => [ + 'menu' => ['skema', 'peta', 'dasbor', 'monitoring'], + 'abilities' => [ + 'skema' => ['read', 'write', 'edit', 'delete'], + 'peta' => ['read'], + 'dasbor' => ['read'], + 'monitoring' => ['read'], + ], + ], + + 'operator impor' => [ + 'menu' => ['impor', 'peta', 'dasbor', 'monitoring'], + 'abilities' => [ + 'impor' => ['read', 'write'], + 'peta' => ['read'], + 'dasbor' => ['read'], + 'monitoring' => ['read'], + ], + ], + + 'analis opd' => [ + 'menu' => ['peta', 'dasbor', 'monitoring'], + 'abilities' => [ + 'peta' => ['read'], + 'dasbor' => ['read'], + 'monitoring' => ['read'], + ], + ], + ]; + + /** + * Permission grup induk — selalu diberikan agar menu utama tampil. + */ + private const PARENT_PERMISSION = 'dataspasial'; + + /** + * Sediakan peran DataSpasial beserta izin menunya (FR-010/FR-012). + * + * Di OpenKab, "peran" dimodelkan sebagai Team (grup) + Role spatie dengan + * `team_id` yang sama — mengikuti pola `2023_06_13_073258_create_role.php` + * dan `App\Console\Commands\updateAdminMenu` (research.md R5). + * + * Dijalankan sebagai data migration agar otomatis tersedia saat deploy + * (`php artisan migrate`). IDEMPOTEN: `Team::firstOrCreate`, + * `Role::firstOrCreate`, dan `Permission::findOrCreate`. + */ + public function up(): void + { + $this->ensureAdministratorAccess(); + + foreach (self::ROLE_PERMISSIONS as $teamName => $config) { + $this->ensureRole($teamName, $config); + } + } + + /** + * Cabut peran & izin DataSpasial saat rollback. + */ + public function down(): void + { + foreach (array_keys(self::ROLE_PERMISSIONS) as $teamName) { + $team = Team::where('name', $teamName)->first(); + + if (! $team) { + continue; + } + + setPermissionsTeamId($team->id); + + Role::where('team_id', $team->id)->where('name', $teamName)->delete(); + + $team->users()->detach(); + $team->delete(); + } + + setPermissionsTeamId(null); + + Permission::where('name', 'like', 'dataspasial%')->delete(); + Artisan::call('admin:menu-update'); + } + + /** + * Beri role `administrator` akses ke menu DataSpasial. + * + * Cukup panggil `admin:menu-update`: command tersebut membaca grup menu + * DataSpasial dari `App\Enums\Modul::Menu` (wajib sudah terdaftar di sana), + * menuliskan menu ke team `administrator`, dan menyinkronkan seluruh + * permission turunannya (`dataspasial-*`) ke role administrator. + * + * Dijaga bila team administrator belum ada (mis. basis data sangat awal). + */ + private function ensureAdministratorAccess(): void + { + if (! Team::where('name', 'administrator')->exists()) { + return; + } + + Artisan::call('admin:menu-update'); + } + + /** + * @param array{menu: array, abilities: array>} $config + */ + private function ensureRole(string $teamName, array $config): void + { + $menu = $this->buildMenu($config['menu'], $config['abilities']); + + $team = Team::firstOrCreate( + ['name' => $teamName], + ['menu' => $menu] + ); + + // Menu sudah ada tetapi belum dalam bentuk yang benar (mis. dibuat + // sebelum penambahan flag ability, atau belum berisi submenu + // DataSpasial) → perbaiki agar hak akses tampil tercentang di UI dan + // tidak terhapus saat grup disimpan. Menu yang sudah benar tidak diubah. + if ($this->needsMenuRepair($team->menu)) { + $team->update(['menu' => $menu]); + } + + setPermissionsTeamId($team->id); + + $role = Role::firstOrCreate([ + 'name' => $teamName, + 'team_id' => $team->id, + 'guard_name' => 'web', + ]); + + $permissionNames = $this->permissionNames($config['abilities']); + + // Gabungkan, bukan ganti: menjamin matriks minimum selalu ada, tetapi + // tidak mencabut izin yang memang ditambahkan admin secara lokal. + $role->syncPermissions(array_values(array_unique(array_merge( + $role->permissions->pluck('name')->all(), + $permissionNames + )))); + } + + /** + * @param array> $abilities + * @return array + */ + private function permissionNames(array $abilities): array + { + $names = [Permission::findOrCreate(self::PARENT_PERMISSION, 'web')->name]; + + foreach ($abilities as $menu => $menuAbilities) { + foreach ($menuAbilities as $ability) { + $names[] = Permission::findOrCreate("dataspasial-{$menu}-{$ability}", 'web')->name; + } + } + + return array_values(array_unique($names)); + } + + /** + * Bangun struktur menu grup DataSpasial berisi hanya submenu yang diizinkan, + * mengikuti bentuk yang dibaca `App\Listeners\MenuListener`. + * + * Setiap item (grup & submenu) menyertakan flag ability + * `"{permission}-read|write|edit|delete"` dan `selected`, yaitu bentuk yang + * dipakai halaman Pengaturan › Grup (`resources/views/group/edit_js.blade.php` + * → `mergeByKey`) untuk mencentang hak akses, dan yang dibaca + * `App\Http\Controllers\Api\TeamController::collectPermissions()` saat + * grup disimpan. Tanpa flag ini kotak centang tidak muncul tercentang dan + * menyimpan grup dari UI akan menghapus izinnya. + * + * @param array $allowedMenus + * @param array> $abilities + * @return array> + */ + private function buildMenu(array $allowedMenus, array $abilities): array + { + $group = $this->dataspasialGroup(); + + if ($group === null) { + return []; + } + + $allowed = array_flip(array_map( + fn (string $menu) => 'dataspasial-'.$menu, + $allowedMenus + )); + + $submenus = []; + $groupAbilities = []; + + foreach ($group['submenu'] ?? [] as $item) { + $permission = $item['permission'] ?? ''; + + if (! isset($allowed[$permission])) { + continue; + } + + // permission submenu = "dataspasial-{slug}" → slug "skema", dst. + $slug = (string) preg_replace('/^dataspasial-/', '', $permission); + $itemAbilities = $abilities[$slug] ?? []; + + $submenus[] = $this->withAbilityFlags($item, $itemAbilities); + $groupAbilities = array_merge($groupAbilities, $itemAbilities); + } + + $groupItem = array_merge($group, ['submenu' => array_values($submenus)]); + + return [ + $this->withAbilityFlags($groupItem, array_values(array_unique($groupAbilities))), + ]; + } + + /** + * Tambahkan flag ability (`{permission}-{ability}`) dan `selected` ke item + * menu, sesuai bentuk yang diproduksi UI Pengaturan › Grup. + * + * @param array $item + * @param array $abilities + * @return array + */ + private function withAbilityFlags(array $item, array $abilities): array + { + $permission = (string) ($item['permission'] ?? ''); + + foreach (['read', 'write', 'edit', 'delete'] as $ability) { + $item[$permission.'-'.$ability] = in_array($ability, $abilities, true); + } + + $item['selected'] = true; + + return $item; + } + + /** + * Apakah menu team perlu diperbaiki? + * + * Dianggap perlu bila grup DataSpasial tidak ada, tidak punya submenu, atau + * item grup/submenu belum memuat flag ability (`{permission}-read`) yang + * dibutuhkan halaman Pengaturan › Grup. + */ + private function needsMenuRepair(mixed $menu): bool + { + foreach ((array) $menu as $group) { + if (($group['text'] ?? null) !== 'DataSpasial') { + continue; + } + + if (empty($group['submenu'])) { + return true; + } + + if (! array_key_exists(($group['permission'] ?? '').'-read', $group)) { + return true; + } + + foreach ($group['submenu'] as $item) { + if (! array_key_exists(($item['permission'] ?? '').'-read', $item)) { + return true; + } + } + + return false; + } + + return true; + } + + /** + * @return array|null + */ + private function dataspasialGroup(): ?array + { + foreach (Modul::Menu as $menu) { + if (($menu['text'] ?? null) === 'DataSpasial') { + return $menu; + } + } + + return null; + } +}; diff --git a/public/js/dataspasial/schema-editor.js b/public/js/dataspasial/schema-editor.js new file mode 100644 index 00000000..00874387 --- /dev/null +++ b/public/js/dataspasial/schema-editor.js @@ -0,0 +1,1341 @@ +/** + * Editor Skema Kabupaten — KAB-02 (OpenSID/OpenKab#1297) + * + * ATURAN YANG WAJIB DIPATUHI (research.md R12): + * - Nama field ASUMSI kontrak API **DILARANG** muncul di berkas ini, termasuk + * pada nama input form. Yang dipakai hanya NAMA INTERNAL (`popupPeta`, + * `sebagaiFilter`, `sebagaiIndikator`, `sumber`, `rujukan`). + * - Yang boleh menyebut nama asumsi hanya `app/Services/SkemaEditorService.php` + * dan `app/Services/DataSpasialApiService.php`. + * - Di sini hanya boleh dipakai NAMA INTERNAL: `popupPeta`, `sebagaiFilter`, + * `sebagaiIndikator`, `sumber`. + * + * Sumber kebenaran tunggal untuk editor + pratinjau adalah `stateIsian` (R7): + * pratinjau merender dari state yang SAMA dengan form, sehingga perubahan yang + * belum disimpan tetap terlihat (FR-035). + * + * Seret-urut memakai NATIVE HTML5 Drag & Drop (R8) — jQuery UI TIDAK terpasang + * pada `node_modules/`, jadi `$(...).sortable()` tidak boleh dipakai. + */ + +const KODE_ISIAN_PATTERN = /^[a-z][a-z0-9_]*$/; + +/** Tipe yang kolom opsinya aktif (cerminan `TipeIsian::memakaiPilihan()`). */ +const TIPE_PILIHAN = 'pilihan'; + +/** Tipe yang kolom satuannya relevan (cerminan `TipeIsian::memakaiSatuan()`). */ +const TIPE_SATUAN = 'angka'; + +/** + * Bangkitkan `kode` isian dari `label` (FR-017, spec Q14). + * + * ⚠️ Ini adalah **satu-satunya** tempat yang membuat `kode`. Admin tidak lagi + * mengetik kode; kolomnya baca-saja dan terisi otomatis. + * + * ⚠️ **TIDAK** menambahkan akhiran angka saat kode bentrok (Q14/C). Label + * duplikat justru harus DITOLAK supaya admin memperbaikinya (lihat + * `validasiKodeLokal()`). + * + * @param {string} label + * @returns {string} kode *snake_case*, atau string kosong bila label tidak + * menghasilkan slug yang sah. + */ +export function slugKodeDariLabel(label) { + const dasar = String(label || '') + .toLowerCase() + .normalize('NFD') + .replace(/[\u0300-\u036f]/g, '') // buang tanda diakritik + .replace(/[^a-z0-9]+/g, '_') // non-alnum → _ + .replace(/_+/g, '_') // rapikan _ + .replace(/^_|_$/g, ''); // rapikan tepian + + // ⚠️ FR-018: pola sah diawali huruf KECIL (bukan angka). + if (dasar === '' || !/^[a-z]/.test(dasar)) { + return ''; + } + + return dasar; +} + +export function isKodeIsianValid(kode) { + return KODE_ISIAN_PATTERN.test(String(kode || '').trim()); +} + +/** + * Buat baris isian kosong dengan bentuk yang sama dengan `form-isian.blade.php`. + * + * @param {number} urutan + */ +function barisBaru(urutan) { + return { + kode: '', + label: '', + tipe: 'teks', + wajib: false, + satuan: '', + opsi: [], + urutan, + popupPeta: false, + sebagaiFilter: false, + sebagaiIndikator: false, + sumber: 'manual', + sumber: 'manual', + aturanHitung: null, + rujukan: null, + }; +} + +/** + * Baca seluruh baris yang sedang tampil di DOM menjadi bentuk state. + * + * DOM adalah sumber pembacaan; `stateIsian` dipakai untuk pratinjau & serialisasi. + * + * @param {HTMLElement} tbody + */ +// --------------------------------------------------------------------------- +// FR-008g / Q27 — pembungkus SweetAlert2. +// +// ⚠️ SELURUH pesan pada halaman editor skema WAJIB lewat `window.Swal` +// (sweetalert2 v11, dimuat pada `index.blade.php`). `window.alert`, +// `window.confirm`, dan `window.prompt` TIDAK BOLEH dipakai. +// +// Fallback ke `window.confirm`/`window.alert` hanya berlaku bila SweetAlert2 +// gagal dimuat (mis. CDN terblokir) agar halaman tetap berfungsi. +// --------------------------------------------------------------------------- + +const swalTersedia = () => typeof window.Swal !== 'undefined'; + +/** Notifikasi hasil aksi (sukses / gagal). */ +const swalPesan = (pesan, ikon = 'info') => { + if (!swalTersedia()) { + window.alert(pesan); + + return Promise.resolve(); + } + + return window.Swal.fire({ + title: pesan, + icon: ikon, + confirmButtonText: 'Mengerti', + customClass: { popup: 'rounded-lg' }, + }); +}; + +const swalSukses = (pesan) => swalPesan(pesan, 'success'); +const swalGalat = (pesan) => swalPesan(pesan, 'error'); +const swalPeringatan = (pesan) => swalPesan(pesan, 'warning'); + +/** Konfirmasi ya/tidak. Mengembalikan Promise. */ +const swalKonfirmasi = async (pesan, judul = 'Konfirmasi') => { + if (!swalTersedia()) { + return window.confirm(pesan); + } + + const hasil = await window.Swal.fire({ + title: judul, + text: pesan, + icon: 'warning', + showCancelButton: true, + confirmButtonText: 'Ya, lanjutkan', + cancelButtonText: 'Batal', + reverseButtons: true, + customClass: { popup: 'rounded-lg' }, + }); + + return hasil.isConfirmed === true; +}; + +/** Input teks tunggal (mis. catatan perubahan penerbitan). */ +const swalInput = async (pesan, judul = 'Masukkan nilai', nilaiAwal = '') => { + if (!swalTersedia()) { + return window.prompt(pesan, nilaiAwal) || ''; + } + + const hasil = await window.Swal.fire({ + title: judul, + input: 'text', + inputLabel: pesan, + inputValue: nilaiAwal, + showCancelButton: true, + confirmButtonText: 'Simpan', + cancelButtonText: 'Batal', + inputValidator: (nilai) => (nilai && nilai.length > 500 ? 'Maksimal 500 karakter' : undefined), + customClass: { popup: 'rounded-lg' }, + }); + + return hasil.isConfirmed === true ? (hasil.value || '') : null; +}; + +/** Escape nilai sebelum disisipkan ke markup SweetAlert2. */ +function escapeHtml(nilai) { + return String(nilai ?? '').replace(/[&<>"']/g, (karakter) => ({ + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''', + }[karakter])); +} + +/** + * Susun rujukan sumber satu baris: "tabel.kolom". + * + * ⚠️ Bentuk API hanya punya SATU kolom `rujukan_sumber` (§6a.4). Dua input + * tabel & kolom pada tabel editor karena itu digabung di sini menjadi satu + * nilai `rujukan`; penyambungan di sisi server ada pada + * `SkemaEditorService::composePemetaan()`. + * + * @param {HTMLElement} tr + * @param {HTMLElement|null} sel Elemen `.sumber-nilai` (pemegang dataset). + * @returns {string} + */ +function rujukanDari(tr, sel) { + const tabel = (tr.querySelector('.input-tabel-sumber')?.value + || sel?.dataset?.tabelSumber || '').trim(); + const kolom = (tr.querySelector('.input-kolom-sumber')?.value + || sel?.dataset?.kolomSumber || '').trim(); + + return [tabel, kolom].filter(Boolean).join('.'); +} + +/** + * Baca opsi satu baris isian. + * + * ⚠️ Kolom "Pilihan" tidak lagi ada di tabel editor (tidak dipakai), sehingga + * nilai opsi diambil dari atribut `data-opsi`. Bila atribut tidak tersedia, + * kembalikan array kosong. + * + * @param {HTMLElement} tr + * @returns {Array} + */ +function bacaOpsiBaris(tr) { + const mentah = tr?.dataset?.opsi; + + if (!mentah || mentah === '[]' || mentah === 'null') { + return []; + } + + try { + const hasil = JSON.parse(mentah); + + return Array.isArray(hasil) ? hasil.filter((v) => typeof v === 'string') : []; + } catch (e) { + return []; + } +} + +function bacaDariDom(tbody) { + return Array.from(tbody.querySelectorAll('tr[data-testid="baris-isian"], tr.baris-baru')) + .map((tr, index) => { + const nilai = (selector) => tr.querySelector(selector); + const sel = nilai('.sumber-nilai'); + + return { + // ⚠️ FR-017/Q14 — `kode` SELALU diturunkan ulang dari `label`, + // tidak pernah dipercaya apa adanya dari input baca-saja. + kode: slugKodeDariLabel(nilai('.input-label')?.value ?? ''), + label: nilai('.input-label')?.value?.trim() ?? '', + tipe: nilai('.input-tipe')?.value ?? 'teks', + wajib: Boolean(nilai('.input-wajib')?.checked), + satuan: nilai('.input-satuan')?.value?.trim() ?? '', + // ⚠️ Kolom Pilihan (opsi) TIDAK lagi ditampilkan pada tabel + // editor. Nilainya dibaca dari atribut `data-opsi` pada baris + // supaya opsi yang tersimpan di API tidak hilang saat Simpan draf. + opsi: bacaOpsiBaris(tr), + urutan: index + 1, + popupPeta: Boolean(nilai('.input-popup-peta')?.checked), + sebagaiFilter: Boolean(nilai('.input-filter')?.checked), + sebagaiIndikator: Boolean(nilai('.input-indikator')?.checked), + sumber: sel?.dataset?.sumber ?? 'manual', + // ⚠️ `rujukan` = "tabel.kolom" adalah nilai yang benar-benar + // disimpan sebagai `rujukan_sumber` (§6a.4). Field tabel/kolom + // di bawah hanya dipakai untuk memulihkan snapshot (Batal). + rujukan: rujukanDari(tr, sel) || null, + tabelSumber: sel?.dataset?.tabelSumber ?? null, + kolomSumber: sel?.dataset?.kolomSumber ?? null, + }; + }); +} + +/** + * Terapkan aturan kolom tipe (FR-010). + * + * @param {HTMLElement} tbody + */ +function terapkanAturanTipe(tbody) { + tbody.querySelectorAll('tr').forEach((tr) => { + const tipe = tr.querySelector('.input-tipe')?.value ?? 'teks'; + const satuan = tr.querySelector('.input-satuan'); + const bolehUbah = tbody.dataset.bolehUbah === '1'; + + if (satuan) { + satuan.disabled = !bolehUbah || tipe !== TIPE_SATUAN; + } + }); +} + +/** + * Render pratinjau dari state yang SAMA dengan form (FR-034/FR-035, R7). + * + * @param {HTMLElement} wadah + * @param {Array} isian + */ +function renderPratinjau(wadah, isian) { + if (!wadah) { + return; + } + + if (isian.length === 0) { + wadah.innerHTML = '

' + + 'Belum ada isian. Pratinjau akan muncul setelah isian pertama ditambahkan.

'; + + return; + } + + wadah.innerHTML = isian.map((baris) => { + const terkunci = baris.sumber && baris.sumber !== 'manual'; + const wajib = baris.wajib + ? '*' + : ''; + + let kontrol; + + if (terkunci) { + kontrol = '
' + + '' + + 'Diisi dari sumber: ' + (baris.sumber || 'pemetaan') + '
'; + } else if (baris.tipe === TIPE_PILIHAN) { + kontrol = ''; + } else if (baris.tipe === 'ya_tidak') { + kontrol = ''; + } else if (baris.tipe === 'tanggal') { + kontrol = ''; + } else { + kontrol = ''; + } + + const catatanTersembunyi = terkunci && !baris.rujukan + ? 'Nilai belum tersedia dari sumber.' + : ''; + + const penanda = []; + if (baris.popupPeta) { penanda.push('Peta'); } + if (baris.sebagaiFilter) { penanda.push('Filter'); } + if (baris.sebagaiIndikator) { penanda.push('Indikator'); } + + return '
' + + '' + + kontrol + + (penanda.length ? '
' + penanda.join('') + '
' : '') + + catatanTersembunyi + + '
'; + }).join(''); +} + +/** + * Serialisasi state ke input tersembunyi milik form POST (FR-029). + * + * ⚠️ Nama key di sini mengikuti aturan validasi `StoreDrafSkemaRequest`; + * pemetaan ke NAMA ASUMSI kontrak terjadi di `SkemaEditorService`. + * + * @param {Array} isian + */ +function serialisasiKeForm(isian) { + const wadah = document.getElementById('wadah-input-isian'); + const form = document.getElementById('form-draf'); + + if (!wadah || !form) { + return; + } + + wadah.innerHTML = ''; + + isian.forEach((baris, index) => { + const tambah = (name, value) => { + const input = document.createElement('input'); + input.type = 'hidden'; + input.name = `isian[${index}][${name}]`; + input.value = value ?? ''; + wadah.appendChild(input); + }; + + tambah('kode', baris.kode); + tambah('label', baris.label); + tambah('tipe', baris.tipe); + tambah('wajib', baris.wajib ? '1' : '0'); + tambah('satuan', baris.satuan || ''); + tambah('urutan', baris.urutan); + + if (baris.tipe === TIPE_PILIHAN) { + baris.opsi.forEach((opsi) => tambah(`opsi[]`, opsi)); + } + + // ⚠️ Nama INTERNAL. Pemetaan ke nama field asumsi kontrak API hanya + // terjadi di `SkemaEditorService::composeDrafPayload()` (research.md R12). + tambah('popupPeta', baris.popupPeta ? '1' : '0'); + tambah('sebagaiFilter', baris.sebagaiFilter ? '1' : '0'); + tambah('sebagaiIndikator', baris.sebagaiIndikator ? '1' : '0'); + + // ⚠️ `sumber` berisi ENUM API (`openSID`/`dataPresisi`/`impor`), + // bukan kode sektor — hasil verifikasi langsung ke API (§6a.5). + if (baris.sumber && baris.sumber !== 'manual') { + tambah('sumber', baris.sumber); + // ⚠️ Satu nilai "tabel.kolom" — inilah yang disimpan sebagai + // `rujukan_sumber` (§6a.4). Tabel & kolom tidak dikirim terpisah. + tambah('rujukan', baris.rujukan || ''); + } + }); +} + +/** Kosongkan pratinjau (dipakai mode "sektor baru", FR-008f). */ +function kosongkanPenandaPratinjau(wadah) { + if (wadah) { + wadah.innerHTML = '

' + + 'Belum ada isian. Pratinjau akan muncul setelah isian pertama ditambahkan.

'; + } +} + +/** + * Validasi keunikan di sisi klien untuk umpan balik INSTAN (FR-018, Q14/Q17). + * + * ⚠️ Ini HANYA untuk UX. Satu-satunya penentu tetap validasi server + * (FR-020: API tidak boleh pernah menerima kode bentrok dari pengguna ini). + * + * ⚠️ Cakupannya **satu versi** yang sedang disunting — baris di luar tabel ini + * (versi sebelumnya) tidak diperiksa (Q17). + * + * ⚠️ Yang dibandingkan adalah `label` (bukan kode) supaya pesan dapat + * langsung ditindaklanjuti admin; kode turunan ikut terisi Unique karena + * diturunkan dari label (Q14). + * + * @param {HTMLElement} tbody + */ +function validasiKodeLokal(tbody) { + const labelDilihat = new Map(); + + tbody.querySelectorAll('tr').forEach((tr) => { + const inputLabel = tr.querySelector('.input-label'); + const galat = tr.querySelector('.galat-kode'); + + if (!inputLabel) { + return; + } + + galat?.remove(); + inputLabel.classList.remove('is-invalid'); + + const label = inputLabel.value.trim(); + + if (label === '') { + return; + } + + // ⚠️ Kolom kode tidak lagi ada di tabel (FR-017/Q14); kodenya hanya + // dihitung saat serialisasi (`bacaDariDom`), bukan ditampilkan. + const kode = slugKodeDariLabel(label); + + let pesan = ''; + + if (kode === '') { + pesan = 'Label ini tidak menghasilkan kode yang sah. Gunakan huruf dan angka, contoh: "Jumlah Siswa".'; + } else if (labelDilihat.has(kode)) { + pesan = `Label "${label}" sudah dipakai isian lain pada versi ini. ` + + 'Ubah labelnya — kode tidak dibuat otomatis dengan akhiran angka.'; + } else { + labelDilihat.set(kode, true); + } + + if (pesan === '') { + return; + } + + inputLabel.classList.add('is-invalid'); + const info = document.createElement('small'); + info.className = 'galat-kode text-danger d-block'; + info.textContent = pesan; + inputLabel.parentElement.appendChild(info); + }); +} + +/** + * Seret-urut NATIVE HTML5 Drag & Drop (FR-012, research.md R8). + * + * @param {HTMLElement} tbody + * @param {Function} onMutasi + */ +function pasangSeretUrutan(tbody, onMutasi) { + if (tbody.dataset.bolehUbah !== '1') { + return; + } + + let yangDiseret = null; + + tbody.addEventListener('dragstart', (event) => { + const tr = event.target.closest('tr'); + + if (!tr) { + return; + } + + yangDiseret = tr; + tr.classList.add('baris-sedang-diseret'); + event.dataTransfer.effectAllowed = 'move'; + // Firefox membutuhkan dataTransfer berisi data agar drag dimulai. + event.dataTransfer.setData('text/plain', ''); + }); + + tbody.addEventListener('dragover', (event) => { + event.preventDefault(); + event.dataTransfer.dropEffect = 'move'; + + const target = event.target.closest('tr'); + + if (!target || target === yangDiseret || !yangDiseret) { + return; + } + + // Sisipkan sebelum / sesudah posisi tetikus. + const kotak = target.getBoundingClientRect(); + const setelah = event.clientY - kotak.top > kotak.height / 2; + + target.parentNode.insertBefore( + yangDiseret, + setelah ? target.nextSibling : target + ); + }); + + tbody.addEventListener('dragend', () => { + yangDiseret?.classList.remove('baris-sedang-diseret'); + yangDiseret = null; + perbaruiNomorUrutan(tbody); + onMutasi(); + }); + + tbody.addEventListener('drop', (event) => event.preventDefault()); +} + +/** + * Tulis ulang kolom `#` menjadi 1..N (FR-012). + * + * @param {HTMLElement} tbody + */ +function perbaruiNomorUrutan(tbody) { + tbody.querySelectorAll('tr').forEach((tr, index) => { + tr.dataset.index = String(index); + + const sel = tr.querySelector('.urutan'); + + if (sel) { + sel.textContent = String(index + 1); + } + }); +} + +export function mountSchemaEditor() { + const akar = document.querySelector('[data-status-editor]'); + const tbody = document.getElementById('isi-tabel-isian'); + + if (!akar || !tbody) { + return; + } + + const wadahPratinjau = document.getElementById('wadah-pratinjau'); + const template = document.getElementById('template-baris-isian'); + // FR-008c/Q24 — `bolehUbah` mutable supaya tombol "Ubah" dapat membuka + // kunci penyuntingan pada skema `terbit` tanpa memuat ulang halaman. + let bolehUbah = tbody.dataset.bolehUbah === '1'; + const sektorKode = akar.dataset.sektor || ''; + const skemaAktif = akar.dataset.skema || ''; + + let dirty = false; + + // Q25 — snapshot isian saat halaman siap, dipakai untuk "Batal" sehingga + // perubahan belum tersimpan dibuang tanpa memuat ulang halaman (nol request). + const snapshotAwal = bacaDariDom(tbody); + + const tombolUbah = document.getElementById('btn-ubah'); + const tombolBatalUbah = document.getElementById('btn-batal-ubah'); + const tombolSimpan = document.getElementById('btn-simpan-draf'); + const inputNamaSektor = document.getElementById('input-sektor-nama-editor'); + const wadahNamaSektor = document.getElementById('wadah-input-sektor-nama'); + let modeUbahAktif = false; + let modeSektorBaru = false; + + /** Aktifkan/nonaktifkan seluruh kontrol isian pada tabel (FR-008c/FR-008e). */ + const kunciKontrolIsian = (aktif) => { + tbody.querySelectorAll( + '.input-label, .input-tipe, .input-wajib, .input-satuan, ' + + '.input-popup-peta, .input-filter, .input-indikator, ' + + '.input-tabel-sumber, .input-kolom-sumber, .btn-pilih-sumber-baris, .btn-hapus-isian', + ).forEach((el) => { + if (el.tagName === 'INPUT') { + el.disabled = !aktif; + } + }); + + // Tombol tambah/seret isian mengikuti kunci yang sama (FR-012). + const tombolTambah = document.getElementById('btn-tambah-isian'); + + if (tombolTambah) { + tombolTambah.disabled = !aktif; + } + + if (inputNamaSektor) { + inputNamaSektor.disabled = !aktif; + } + + tbody.dataset.bolehUbah = aktif ? '1' : '0'; + terapkanAturanTipe(tbody); + + // ⚠️ Baris kosong yang ditambahkan lewat template juga ikut terkunci + // pada keadaan baca-saja (FR-008a). Tanpa ini, baris baru akan tetap + // aktif walau skema sedang `terbit`. + if (!aktif) { + template?.content.querySelectorAll( + '.input-label, .input-tipe, .input-wajib, .input-satuan, ' + + '.input-popup-peta, .input-filter, .input-indikator', + ).forEach((el) => { + if (el.tagName === 'INPUT') { + el.disabled = true; + } + }); + } + }; + + /** Pulihkan nilai kontrol isian dari snapshot (Q25). */ + const pulihkanDariSnapshot = () => { + const baris = Array.from(tbody.querySelectorAll('tr[data-testid="baris-isian"], tr.baris-baru')); + + baris.forEach((tr, index) => { + const sumber = snapshotAwal[index]; + + if (!sumber) { + return; + } + + const label = tr.querySelector('.input-label'); + const tipe = tr.querySelector('.input-tipe'); + const wajib = tr.querySelector('.input-wajib'); + const satuan = tr.querySelector('.input-satuan'); + const sumberNilai = tr.querySelector('.sumber-nilai'); + + if (label) { + label.value = sumber.label ?? ''; + } + + if (tipe) { + tipe.value = sumber.tipe ?? 'teks'; + } + + if (wajib) { + wajib.checked = Boolean(sumber.wajib); + } + + if (satuan) { + satuan.value = sumber.satuan ?? ''; + } + + // ⚠️ Kolom Pilihan tidak dirender; opsi dipulihkan lewat `data-opsi` + // yang dipakai `bacaOpsiBaris()` saat serialize. + tr.dataset.opsi = JSON.stringify(sumber.opsi ?? []); + + if (sumberNilai) { + sumberNilai.dataset.sumber = sumber.sumber ?? 'manual'; + sumberNilai.dataset.tabelSumber = sumber.tabelSumber ?? ''; + sumberNilai.dataset.kolomSumber = sumber.kolomSumber ?? ''; + sumberNilai.dataset.rujukan = sumber.rujukan || ''; + + const tabel = tr.querySelector('.input-tabel-sumber'); + const kolom = tr.querySelector('.input-kolom-sumber'); + + if (tabel) { + tabel.value = sumber.tabelSumber ?? ''; + } + + if (kolom) { + kolom.value = sumber.kolomSumber ?? ''; + } + } + }); + + renderPratinjau(wadahPratinjau, bacaDariDom(tbody)); + }; + + /** + * FR-008c (Q24) — tombol "Ubah": aksi klien semata, TANPA permintaan ke + * sumber data. Membuka kunci isian, mengaktifkan Simpan draf, menyembunyikan + * tombol Ubah, dan menampilkan tombol Batal. + */ + tombolUbah?.addEventListener('click', () => { + modeUbahAktif = true; + bolehUbah = true; + kunciKontrolIsian(true); + + if (tombolSimpan) { + tombolSimpan.disabled = false; + } + + tombolUbah.classList.add('d-none'); + tombolBatalUbah?.classList.remove('d-none'); + perbaruiTombolTerbit(); + }); + + /** + * FR-008e (Q25) — tombol "Batal": mengembalikan editor ke baca-saja + * `terbit` tanpa permintaan ke sumber data; perubahan belum tersimpan dibuang. + */ + tombolBatalUbah?.addEventListener('click', () => { + modeUbahAktif = false; + + // FR-008f — Batal pada mode "sektor baru" menutup mode itu tanpa + // menyimpan apa pun dan tanpa permintaan ke sumber data. + if (modeSektorBaru) { + modeSektorBaru = false; + kosongkanTabelIsian(); + kosongkanPenandaPratinjau(wadahPratinjau); + + if (inputNamaSektor) { + inputNamaSektor.value = akar.dataset.sektorNama || ''; + } + + kunciKontrolIsian(false); + + // Bila memang tidak ada sektor yang dipilih, kembalikan blok nama + // sektor ke kondisi tersembunyi seperti semula. + if (sektorKode === '') { + wadahNamaSektor?.classList.add('d-none'); + } + + document.getElementById('isian-belum-pilih-sektor')?.classList.remove('d-none'); + + if (tombolSimpan) { + tombolSimpan.disabled = true; + } + + tombolBatalUbah.classList.add('d-none'); + tombolUbah?.classList.remove('d-none'); + perbaruiTombolTerbit(); + + return; + } + + pulihkanDariSnapshot(); + bolehUbah = false; + kunciKontrolIsian(false); + + if (tombolSimpan) { + tombolSimpan.disabled = true; + } + + tombolBatalUbah.classList.add('d-none'); + tombolUbah?.classList.remove('d-none'); + dirty = false; + perbaruiNomorUrutan(tbody); + perbaruiTombolTerbit(); + }); + + const mutasi = () => { + dirty = true; + perbaruiNomorUrutan(tbody); + terapkanAturanTipe(tbody); + validasiKodeLokal(tbody); + renderPratinjau(wadahPratinjau, bacaDariDom(tbody)); + // FR-045 — perubahan baru membuat penerbitan tidak boleh dipakai. + perbaruiTombolTerbit(); + }; + + const pasangKonfirmasi = () => { + if (!dirty) { + return true; + } + + const pesan = 'Ada perubahan yang belum disimpan. Tinggalkan halaman?'; + + return swalKonfirmasi(pesan, 'Perubahan belum disimpan'); + }; + + // ----------------------------------------------------------------------- + // Tambah isian (US1#13, US2) + // ----------------------------------------------------------------------- + document.getElementById('btn-tambah-isian')?.addEventListener('click', async () => { + if (!template) { + // ⚠️ Guard: template WAJIB selalu ada di DOM (lihat + // tabel-isian.blade.php). Bila hilang, beri umpan balik alih-alih + // diam-diam tidak terjadi apa pun (FR-008g). + await swalGalat('Fungsi tambah isian belum siap. Muat ulang halaman.'); + + return; + } + + if (!bolehUbah) { + await swalPeringatan('Skema ini sedang dibaca. Tekan Ubah untuk menyuntingnya.'); + + return; + } + + tbody.appendChild(template.content.cloneNode(true)); + perbaruiNomorUrutan(tbody); + mutasi(); + + // ⚠️ FR-017/Q14 — `kode` kini baca-saja, jadi fokus awal ke ke `label` + // yang menjadi sumber kebenaran kode. + tbody.querySelector('tr:last-child .input-label')?.focus(); + }); + + // ----------------------------------------------------------------------- + // Hapus isian (FR-014, US2#4/#6) + // ----------------------------------------------------------------------- + tbody.addEventListener('click', async (event) => { + const tombol = event.target.closest('.btn-hapus-isian'); + + if (!tombol) { + return; + } + + const tr = tombol.closest('tr'); + // ⚠️ Q14 — admin tidak lagi mengetik `kode`, jadi konfirmasi menyebut + // LABEL yang mereka lihat di tabel. `data-kode` dipakai sebagai cadangan + // untuk baris yang labelnya kosong. + const label = tr?.querySelector('.input-label')?.value?.trim() + || tombol.dataset.kode + || '(tanpa label)'; + const terpetakan = tombol.dataset.terpetakan === '1'; + + const pesan = terpetakan + ? `Hapus isian "${label}"? Pemetaan sumber nilainya juga akan dilepas.` + : `Hapus isian "${label}"?`; + + if (!(await swalKonfirmasi(pesan, 'Hapus isian'))) { + return; + } + + tr?.remove(); + perbaruiNomorUrutan(tbody); + mutasi(); + }); + + // ----------------------------------------------------------------------- + // Tambah sektor (US1#8) + // ----------------------------------------------------------------------- + // ----------------------------------------------------------------------- + // FR-008f / Q26 — mode "sektor baru". + // + // ⚠️ Tidak ada lagi formulir terpisah: tombol ini membuka mode penyuntingan + // yang SAMA dengan tombol "Ubah". Sektor + isian pertama tersimpan + // bersamaan pada satu POST /dataspasial/skema, dan **tidak ada** permintaan + // ke sumber data selama mode ini aktif (Q19/Q23/Q26). + // ----------------------------------------------------------------------- + const kosongkanTabelIsian = () => { + tbody.querySelectorAll('tr[data-testid="baris-isian"], tr.baris-baru').forEach((tr) => { + if (!tr.classList.contains('baris-baru')) { + tr.remove(); + } + }); + }; + + document.getElementById('btn-tambah-sektor')?.addEventListener('click', () => { + modeSektorBaru = true; + modeUbahAktif = false; + dirty = false; + + // ⚠️ Mode "sektor baru" adalah keadaan MENYUNTING — `bolehUbah` harus + // true supaya guard tombol Tambah Isian Baru tidak salah memberi tahu + // "Skema ini sedang dibaca" (FR-008f). + bolehUbah = true; + + if (inputNamaSektor) { + inputNamaSektor.value = ''; + } + + kosongkanTabelIsian(); + kosongkanPenandaPratinjau(wadahPratinjau); + kunciKontrolIsian(true); + + // ⚠️ Blok nama sektor disembunyikan Blade saat belum ada sektor terpilih; + // pada mode "sektor baru" blok ini WAJIB dibuka supaya admin punya + // kolom untuk diisi nama (FR-008f). + wadahNamaSektor?.classList.remove('d-none'); + + // Pesan "pilih salah satu sektor" tidak relevan selama mode ini aktif. + document.getElementById('isian-belum-pilih-sektor')?.classList.add('d-none'); + + if (tombolSimpan) { + tombolSimpan.disabled = false; + } + + if (tombolTerbit) { + tombolTerbit.disabled = true; + tombolTerbit.title = 'Terbitkan setelah draf tersimpan.'; + } + + tombolUbah?.classList.add('d-none'); + tombolBatalUbah?.classList.remove('d-none'); + inputNamaSektor?.focus(); + }); + + // ----------------------------------------------------------------------- + // Simpan draf (US3) — pemicu konfirmasi yang sama (FR-007h) + // ----------------------------------------------------------------------- + document.getElementById('btn-simpan-draf')?.addEventListener('click', async () => { + if (!bolehUbah) { + return; + } + + // FR-008f — mode "sektor baru": nama sektor wajib diisi sebelum simpan. + if (modeSektorBaru && ((inputNamaSektor?.value ?? '').trim() === '')) { + await swalPeringatan('Nama sektor wajib diisi.'); + + return; + } + + // ⚠️ Q22 — editor hanya menampilkan SATU skema (versi terakhir), jadi + // tidak ada lagi daftar versi yang perlu dicek untuk "draf sudah ada". + const isian = bacaDariDom(tbody); + + if (isian.length === 0) { + await swalPeringatan('Sektor harus memiliki minimal satu isian.'); + + return; + } + + // ⚠️ FR-018/Q14/Q17 — yang dibandingkan adalah `label`; kode turunan + // otomatis unik kalau label-nya unik. Cakupannya satu versi ini saja. + const labelBentrok = isian + .map((b) => b.label) + .filter((label, i, arr) => label !== '' && arr.indexOf(label) !== i); + + if (labelBentrok.length > 0) { + await swalPeringatan(`Label "${labelBentrok[0]}" dipakai lebih dari satu isian pada versi ini.`); + + return; + } + + const kodeKosong = isian.find((b) => b.kode === ''); + + if (kodeKosong) { + await swalPeringatan(`Label "${kodeKosong.label}" tidak menghasilkan kode yang sah. Perbaiki labelnya.`); + + return; + } + + serialisasiKeForm(isian); + dirty = false; + perbaruiTombolTerbit(); + + // US7/Q19 — nama sektor SELALU diambil dari input yang dapat disunting; + // pada mode "sektor baru" (Q26) input itu sengaja dikosongkan oleh sistem. + const inputNama = document.getElementById('input-sektor-nama-editor'); + const sektorNama = (inputNama?.value ?? '').trim() + || akar.dataset.sektorNama + || ''; + + // ⚠️ FR-008f — pada mode "sektor baru" kode sektor BELUM ada; backend + // membangkitkannya dari nama (Q5/FR-007d-1), jadi klien mengirim kosong. + const sektorKodeKirim = modeSektorBaru ? '' : (sektorKode || ''); + const token = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') + ?? document.querySelector('input[name="_token"]')?.value + ?? ''; + + try { + const respons = await fetch('/dataspasial/skema/draf', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + 'X-Requested-With': 'XMLHttpRequest', + 'X-CSRF-TOKEN': token, + }, + credentials: 'same-origin', + body: JSON.stringify({ + sektor: sektorKodeKirim, + sektor_nama: sektorNama, + keterangan: '', + isian, + }), + }); + + if (respons.status === 422) { + const data = await respons.json().catch(() => ({})); + await swalPeringatan((data && data.message) || 'Validasi gagal. Periksa isian dan coba lagi.'); + dirty = true; + perbaruiTombolTerbit(); + return; + } + + if (!respons.ok) { + await swalGalat('Data sementara tidak tersedia. Silakan coba lagi nanti.'); + dirty = true; + perbaruiTombolTerbit(); + return; + } + + await swalSukses(modeSektorBaru ? 'Sektor berhasil dibuat.' : 'Draf skema berhasil disimpan.'); + } catch (error) { + await swalGalat('Data sementara tidak tersedia. Silakan coba lagi nanti.'); + dirty = true; + perbaruiTombolTerbit(); + } + }); + + // ----------------------------------------------------------------------- + // Terbitkan draf (US8, FR-041 … FR-045) + // ----------------------------------------------------------------------- + const tombolTerbit = document.getElementById('btn-terbitan'); + const formTerbit = document.getElementById('form-terbit'); + + // ⚠️ Keadaan awal berasal dari server (FR-041): tombol hanya aktif bila + // skema berstatus `draf`. Untuk status `terbit` tombol **tetap** nonaktif — + // dirty-guard hanya boleh MENONAKAN tombol, tidak boleh menghidupkannya. + const bolehTerbitAwal = tombolTerbit ? !tombolTerbit.disabled : false; + + /** + * FR-045 — penerbitan menghasilkan versi immutable, jadi isian yang belum + * disimpan tidak boleh diterbitkan. Tombol dinonaktifkan selama `dirty`. + */ + const perbaruiTombolTerbit = () => { + if (!tombolTerbit || !bolehTerbitAwal) { + return; + } + + // FR-008d/Q24 — pada mode "Ubah" (draf baru dari versi terbit), tombol + // Terbitan tetap nonaktif sampai draf benar-benar tersimpan (FR-045). + if (modeUbahAktif) { + tombolTerbit.disabled = true; + tombolTerbit.title = 'Simpan draf dulu — ada perubahan yang belum disimpan.'; + + return; + } + + if (dirty) { + tombolTerbit.disabled = true; + tombolTerbit.title = 'Simpan draf dulu — ada perubahan yang belum disimpan.'; + + return; + } + + tombolTerbit.disabled = false; + tombolTerbit.removeAttribute('title'); + }; + + // Panggil sekali setelah DOM siap agar tombol sesuai keadaan awal. + perbaruiTombolTerbit(); + + tombolTerbit?.addEventListener('click', async () => { + if (!formTerbit || !sektorKode) { + return; + } + + // FR-045 — pengaman kedua di sisi klien. + if (dirty) { + await swalPeringatan('Simpan draf dulu. Ada perubahan yang belum disimpan.'); + + return; + } + + const namaSektor = (document.getElementById('input-sektor-nama-editor')?.value ?? '').trim() + || document.querySelector('[data-testid="item-sektor"][data-sektor-kode="' + sektorKode + '"]') + ?.dataset?.sektorNama + || sektorKode; + + // FR-042/Q18 — nomor versi ditentukan API, jadi konfirmasi TIDAK lagi + // menyebut angka; ia menyatakan bahwa versi baru immutable akan dibuat. + const pesan = `Terbitkan draf sektor "${namaSektor}"?\n\n` + + 'Penerbitan membuat versi baru yang immutable dan tidak dapat diubah lagi. ' + + 'Nomor versinya ditentukan sistem.'; + + if (!(await swalKonfirmasi(pesan, 'Terbitkan skema'))) { + return; + } + + const inputCatatan = formTerbit.querySelector('[name="catatan_perubahan"]'); + const hasilInput = await swalInput('Catatan perubahan (opsional)', 'Catatan perubahan', ''); + + if (hasilInput === null) { + return; + } + + const catatan = hasilInput; + + if (inputCatatan) { + inputCatatan.value = catatan; + } + + // Q21 — terbitkan lewat AJAX, tanpa memuat ulang halaman. + const token = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') + ?? inputCatatan?.value + ?? ''; + + fetch('/dataspasial/skema/terbit', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + 'X-Requested-With': 'XMLHttpRequest', + 'X-CSRF-TOKEN': token, + }, + credentials: 'same-origin', + body: JSON.stringify({ sektor: sektorKode, catatan_perubahan: catatan }), + }) + .then(async (respons) => { + const data = await respons.json().catch(() => ({})); + + if (!respons.ok) { + window.Swal.fire({ title: data.message || 'Data sementara tidak tersedia. Silakan coba lagi nanti.', icon: 'error', confirmButtonText: 'Mengerti' }); + + return; + } + + window.Swal.fire({ title: data.message || 'Skema berhasil diterbitkan.', icon: 'success', confirmButtonText: 'Mengerti' }); + window.location.reload(); + }) + .catch(() => { + window.Swal.fire({ title: 'Data sementara tidak tersedia. Silakan coba lagi nanti.', icon: 'error', confirmButtonText: 'Mengerti' }); + }); + }); + + // ----------------------------------------------------------------------- + // Modal pemilihan sumber nilai (US4, FR-022 … FR-025, Q20) + // + // ⚠️ Q20 — pemetaan memakai `{sumber, rujukan_sumber}` dan TIDAK memakai + // katalog/daftar sektor. Pilihan yang disediakan dua mode: + // **Manual** (isian diisi operator desa) atau **Otomatis** dari sumber data. + // Modal **terisi dengan nilai saat ini** sehingga admin dapat melanjutkan + // atau memperbaiki pemetaan yang sudah ada tanpa mengetik ulang. + // ----------------------------------------------------------------------- + const SUMBER_ENUM = ['openSID', 'dataPresisi', 'impor']; + + /** Terapkan pilihan sumber nilai ke satu baris isian. */ + const tulisPemetaan = (tr, { sumber, tabel, kolom }) => { + const sel = tr.querySelector('.sumber-nilai'); + + if (!sel) { + return; + } + + sel.dataset.sumber = sumber || 'manual'; + sel.dataset.tabelSumber = tabel || ''; + sel.dataset.kolomSumber = kolom || ''; + // ⚠️ `rujukan` harus selalu konsisten dengan tabel.kolom karena inilah + // nilai yang dikirim ke API sebagai `rujukan_sumber` (§6a.4). + sel.dataset.rujukan = [tabel || '', kolom || ''].filter(Boolean).join('.'); + + const tombol = sel.querySelector('.btn-pilih-sumber-baris'); + const badge = tombol?.querySelector('.badge') ?? sel.querySelector('.badge'); + + if (!badge) { + return; + } + + if (sumber && sumber !== 'manual') { + badge.className = 'badge badge-info'; + badge.innerHTML = '' + sumber; + } else { + badge.className = 'badge badge-secondary'; + badge.textContent = 'Manual'; + } + + // Input tabel/kolom ikut diperbarui agar `bacaDariDom()` membaca + // nilai yang sama dengan yang tampil. + const inputTabel = sel.querySelector('.input-tabel-sumber'); + const inputKolom = sel.querySelector('.input-kolom-sumber'); + + if (inputTabel) { + inputTabel.value = tabel || ''; + } + + if (inputKolom) { + inputKolom.value = kolom || ''; + } + }; + + /** Tampilkan modal pemilihan sumber nilai via SweetAlert2 (FR-008g). */ + const bukaModalSumberNilai = async (tr) => { + if (!swalTersedia()) { + await swalGalat('Pemilih sumber nilai belum siap. Muat ulang halaman.'); + + return; + } + + const label = tr.querySelector('.input-label')?.value || '(tanpa label)'; + + // ⚠️ Modal TERISI dengan nilai saat ini agar admin dapat melanjutkan / + // memperbaiki pemetaan yang sudah ada — bukan dimulai dari kosong. + const sel = tr.querySelector('.sumber-nilai'); + const sumberSekarang = sel?.dataset?.sumber || 'manual'; + const otomatisSekarang = sumberSekarang !== '' && sumberSekarang !== 'manual'; + const tabelSekarang = sel?.dataset?.tabelSumber || ''; + const kolomSekarang = sel?.dataset?.kolomSumber || ''; + + const hasil = await window.Swal.fire({ + title: 'Sumber nilai isian', + html: ` +

Isian: ${label}

+
+ + +
+
+ + +
+ `, + showCancelButton: true, + confirmButtonText: 'Simpan', + cancelButtonText: 'Batal', + focusConfirm: false, + preConfirm: () => { + const otomatis = document.getElementById('sumber-mode-otomatis')?.checked; + + if (!otomatis) { + return { sumber: 'manual', tabel: '', kolom: '' }; + } + + const sumber = document.getElementById('sumber-pilihan')?.value || ''; + const tabel = (document.getElementById('sumber-tabel')?.value || '').trim(); + const kolom = (document.getElementById('sumber-kolom')?.value || '').trim(); + + // FR-022/Q20 — bila otomatis, `sumber`, `tabel_sumber`, dan + // `kolom_sumber` seluruhnya wajib terisi. + if (!sumber || tabel === '' || kolom === '') { + window.Swal.showValidationMessage('Sumber data, tabel, dan kolom wajib diisi.'); + + return false; + } + + return { sumber, tabel, kolom }; + }, + didOpen: (popup) => { + const manual = popup.querySelector('#sumber-mode-manual'); + const otomatis = popup.querySelector('#sumber-mode-otomatis'); + const bagian = popup.querySelector('#bagian-otomatis'); + + const sinkron = () => { + bagian.style.display = otomatis.checked ? 'block' : 'none'; + }; + + manual.addEventListener('change', sinkron); + otomatis.addEventListener('change', sinkron); + sinkron(); + }, + customClass: { popup: 'rounded-lg' }, + }); + + if (!hasil.isConfirmed || !hasil.value) { + return; + } + + tulisPemetaan(tr, hasil.value); + mutasi(); + }; + + tbody.addEventListener('click', (event) => { + const tombol = event.target.closest('.btn-pilih-sumber-baris'); + + if (!tombol) { + return; + } + + const tr = tombol.closest('tr'); + + if (!tr) { + return; + } + + if (!bolehUbah) { + swalPeringatan('Skema ini sedang dibaca. Tekan Ubah untuk menyuntingnya.'); + + return; + } + + bukaModalSumberNilai(tr); + }); + + // ----------------------------------------------------------------------- + // Dirty-guard (FR-016, R9) + // ----------------------------------------------------------------------- + tbody.addEventListener('input', (event) => { + dirty = true; + + // FR-017/Q14 — mengubah `label` langsung membangkitkan ulang `kode`, + // lalu memeriksa keunikannya per versi (FR-018/Q17). Harus jalan + // SEBELUM pratinjau supaya pratinjau memakai kode terbaru. + if (event.target.classList.contains('input-label')) { + validasiKodeLokal(tbody); + } + + renderPratinjau(wadahPratinjau, bacaDariDom(tbody)); + // FR-045 — mengetik di tabel ikut membuat penerbitan tidak boleh dipakai. + perbaruiTombolTerbit(); + }); + + tbody.addEventListener('change', (event) => { + if (event.target.classList.contains('input-tipe')) { + terapkanAturanTipe(tbody); + } + + mutasi(); + }); + + window.addEventListener('beforeunload', (event) => { + if (!dirty) { + return undefined; + } + + event.preventDefault(); + event.returnValue = ''; + + return ''; + }); + + document.addEventListener('click', (event) => { + const sektor = event.target.closest('[data-sektor-kode] a'); + const skema = event.target.closest('[data-skema-id] a'); + + if (sektor || skema) { + pasangKonfirmasi(); + } + }); + + // ----------------------------------------------------------------------- + // Inisialisasi + // ----------------------------------------------------------------------- + pasangSeretUrutan(tbody, mutasi); + perbaruiNomorUrutan(tbody); + terapkanAturanTipe(tbody); + validasiKodeLokal(tbody); + renderPratinjau(wadahPratinjau, bacaDariDom(tbody)); +} + + +// Dimuat sebagai ` + +@endpush \ No newline at end of file diff --git a/resources/views/dataspasial/skema/partials/form-isian.blade.php b/resources/views/dataspasial/skema/partials/form-isian.blade.php new file mode 100644 index 00000000..c51d36e2 --- /dev/null +++ b/resources/views/dataspasial/skema/partials/form-isian.blade.php @@ -0,0 +1,134 @@ +{{-- + Satu baris tabel isian = satu kolom formulir desa (FR-009). + + ⚠️ Nama field di sini adalah NAMA INTERNAL (`popupPeta`, `sebagaiFilter`, + `sebagaiIndikator`, ). Nama field ASUMSI kontrak API + (`popup_peta`, `filter`, `indikator`, `pemetaan`) **dilarang** muncul di + view — pemetaannya hanya di `SkemaEditorService` (research.md R12). + + ⚠️ Kolom satuan hanya relevan bila tipe `angka` (FR-010, + `TipeIsian::memakaiSatuan()`). +--}} +@php + $tipe = \App\Enums\TipeIsian::hasValue($baris['tipe']) + ? \App\Enums\TipeIsian::fromValue($baris['tipe']) + : \App\Enums\TipeIsian::Teks(); + $memakaiSatuan = $tipe->memakaiSatuan(); + // ⚠️ VERIFIKASI LANGSUNG: `sumber` berisi ENUM API (`openSID`/`dataPresisi`/ + // `impor`), BUKAN kode sektor dan bukan literal 'pemetaan' (kontrak §6a.5). + $sumber = $baris['sumber'] ?? 'manual'; + $terpetakan = $sumber !== null && $sumber !== '' && $sumber !== 'manual'; +@endphp + + + + {{-- Handle seret (FR-012). --}} + + @if ($bolehUbah) + + @endif + + + {{ $baris['urutan'] }} + + + + @error("isian.{$index}.label") +
{{ $message }}
+ @enderror + + + + + @error("isian.{$index}.tipe") +
{{ $message }}
+ @enderror + + + + + + + + + + + {{-- + ⚠️ Kolom **Pilihan (opsi) tidak lagi ditampilkan** — tidak dipakai pada + editor ini. Nilai `opsi` yang tersimpan di API tetap **dipertahankan** + melalui atribut `data-opsi` pada baris sehingga `bacaDariDom()` + mengirimkannya kembali saat Simpan draf dan tidak terjadi kehilangan data. + --}} + + {{-- + Sumber nilai: manual atau pemetaan (FR-023). + Memakai `data-*` sehingga JS bisa menulis/membaca status pemetaan + tanpa perlu mem-parsing teks badge. + --}} + + @if ($bolehUbah) + + @else + @if ($terpetakan) + {{ $sumber }} + {{ $baris['tabelSumber'] ?? '' }}.{{ $baris['kolomSumber'] ?? '' }} + @else + Manual + @endif + @endif + + + + + + + + + + + + + + + + @if ($bolehUbah) + + @endif + + \ No newline at end of file diff --git a/resources/views/dataspasial/skema/partials/panel-sektor.blade.php b/resources/views/dataspasial/skema/partials/panel-sektor.blade.php new file mode 100644 index 00000000..f1ad1add --- /dev/null +++ b/resources/views/dataspasial/skema/partials/panel-sektor.blade.php @@ -0,0 +1,90 @@ +{{-- + Panel kiri: daftar sektor (FR-007) + tombol tambah sektor (FR-007c). + + Kode sektor ditampilkan sebagai BACA-SAJA (FR-007d-3) karena kode dibangkitkan + oleh API dan dijamin unik global (research.md R4) — admin tidak mengeditnya. +--}} +
+
+

Sektor

+
+ +
+ @if (count($daftarSektor) === 0) +

+ Belum ada sektor. Tambahkan sektor pertama untuk mulai menyusun skema. +

+ @else + + @endif +
+ + {{-- Tombol tambah sektor: selalu terlihat (FR-007c). --}} + +
diff --git a/resources/views/dataspasial/skema/partials/pratinjau.blade.php b/resources/views/dataspasial/skema/partials/pratinjau.blade.php new file mode 100644 index 00000000..191da64b --- /dev/null +++ b/resources/views/dataspasial/skema/partials/pratinjau.blade.php @@ -0,0 +1,45 @@ +{{-- + Panel kanan: pratinjau formulir desa (FR-034). + + Pratinjau dibangun dari STATE KLIEN yang sama dengan form editor + (`renderPratinjau(stateIsian)` di `schema-editor.js`) agar perubahan yang + BELUM disimpan tetap terlihat (FR-035, research.md R7). + + Isian terpetakan tampil terkunci dengan keterangan sumbernya (FR-015). +--}} +
+
+

+ Pratinjau Formulir Desa +

+
+ +
+ @if ($sektorTerpilih === null) +

+ Pratinjau formulir akan muncul setelah sektor dipilih. +

+ @else +
+ Sektor +
{{ $sektorTerpilih['nama'] }}
+ + Isian selalu dimuat dari skema terakhir sektor ini + (draf bila ada, terbit baca-saja bila tidak). + +
+ +
+ + {{-- Dikosongkan oleh JS lalu diisi `renderPratinjau()`. --}} +
+ + + @endif +
+
\ No newline at end of file diff --git a/resources/views/dataspasial/skema/partials/tabel-isian.blade.php b/resources/views/dataspasial/skema/partials/tabel-isian.blade.php new file mode 100644 index 00000000..b4e3c887 --- /dev/null +++ b/resources/views/dataspasial/skema/partials/tabel-isian.blade.php @@ -0,0 +1,237 @@ +{{-- + Panel tengah: tabel isian (FR-013). + + ⚠️ Kolom **Kode tidak lagi ditampilkan** (FR-017/Q14). `kode` adalah nilai + turunan dari `label` yang dibuat otomatis oleh aplikasi, sehingga tidak ada + kolom yang perlu diisi admin. + + Kolom memuat: label, tipe, wajib, satuan, sumber nilai (manual atau + otomatis), popup peta, filter, dan indikator. + + ⚠️ Kolom **Pilihan (opsi)** tidak lagi ditampilkan karena tidak dipakai + pada editor ini; nilai opsi yang sudah tersimpan di API tetap dikirim ulang + lewat atribut `data-opsi` agar tidak hilang. + + Kolom popup/filter/indikator & sumber nilai diisi penuh pada US4/US5; + kolomnya sudah disiapkan sejak US1 agar tata letak tidak berubah + saat story berikutnya tiba (FR-008). +--}} +
+
+

+ Isian + @if ($sektorTerpilih) + · {{ $sektorTerpilih['kode'] }} + @endif +

+ + {{-- + US7/Q19 — nama sektor disunting dari editor, dikirim sebagai + `sektor_nama` pada setiap Simpan draf (FR-007d-4/FR-007d-9). + Kode sektor tetap baca-saja (FR-007d-5). + + ⚠️ **Selalu dirender**, termasuk ketika belum ada sektor yang dipilih. + Bila input ini hanya dirender bila `$sektorTerpilih`, maka mode + "sektor baru" (FR-008f/Q26) tidak punya kolom untuk diisi — admin + menekan "Tambah Sektor Baru" tetapi tidak ada apa pun yang muncul. + Saat belum ada sektor terpilih, blok ini disembunyikan dan dibuka + kembali oleh JS ketika mode "sektor baru" diaktifkan. + --}} +
+ + + @error('sektor_nama') +
{{ $message }}
+ @enderror +
+ +
+ {{-- Simpan draf: aktif hanya bila skema berstatus draf (FR-008b). --}} + + +{{-- + Terbitkan (US8, FR-041): aktif bila skema berstatus `draf`. + Frontend MENONAKAN tombol saat ada perubahan yang belum disimpan + (FR-045) — penerbitan menghasilkan versi immutable. + --}} + + + {{-- + FR-008c (Q24) — tombol "Ubah": HANYA dirender saat skema berstatus + `terbit`. Aksi klien semata (nol request ke sumber data): membuka + kunci penyuntingan isian dan mengaktifkan Simpan draf. Setelah + diklik, tombol ini disembunyikan oleh JS. + --}} + @if ($statusEditor->value === 'terbit') + + @endif + + {{-- + FR-008e (Q25) — tombol "Batal": hanya tampil pada keadaan + "menyunting draf hasil Ubah"; mengembalikan editor ke baca-saja + `terbit` tanpa permintaan ke sumber data. + --}} + +
+
+ + @if ($statusEditor->value === 'terbit') + {{-- Skema terbit hanya baca-saja (FR-008a). --}} + + @endif + +
+ @if ($sektorTerpilih === null) +

+ Pilih salah satu sektor untuk menyusun isiannya. +

+ @elseif (count($isian) === 0) +

+ Belum ada isian pada skema ini. Tekan Tambah Isian Baru untuk + menambahkan kolom formulir pertama. +

+ @endif + +
+ + + + + + + + + + + + + + + + + + @foreach ($isian as $index => $baris) + @include('dataspasial.skema.partials.form-isian', [ + 'index' => $index, + 'baris' => $baris, + 'bolehUbah' => $bolehUbah, + 'daftarSektorSumber' => $daftarSektorSumber ?? [], + ]) + @endforeach + +
 #LabelTipeWajibSatuanSumber nilaiPetaFilterIndikator 
+
+
+ + {{-- + Template baris kosong untuk "Tambah Isian Baru" (US1#13). + + ⚠️ **Selalu dirender**, termasuk saat skema `terbit`. Alasannya: tombol + "Tambah Isian Baru" dapat diaktifkan kemudian melalui mode penyuntingan + (tombol **Ubah** — FR-008c/Q24, atau **Tambah Sektor Baru** — + FR-008f/Q26). Bila template hanya dirender saat `$bolehUbah`, elemennya + tidak ada di DOM dan handler klik akan bail-out tanpa efek sehingga + tombol tampak tidak berfungsi. + + Keadaan read-only dijaga di sisi klien: saat skema `terbit` tombol simpan + **nonaktif** sehingga baris baru tidak pernah dibuat, dan bila baris + sudah ada kontrolnya dikunci oleh `kunciKontrolIsian()` (FR-008a). + --}} + + + {{-- Tombol tambah isian: selalu terlihat (FR-007c). --}} + +
+ +{{-- Form submit draf. Isian diserialisasi oleh JS dari state yang sama. --}} +
+ @csrf + + + +
+
+ +{{-- + Form submit terbitan (US8, FR-041). + + ⚠️ `sektor` yang dikirim adalah **KODE** sektor karena `{sektor}` pada + path `POST /dataspasial/skema/{sektor}/terbit` adalah kode sektor. +--}} +
+ @csrf + + + +
\ No newline at end of file diff --git a/resources/views/dataspasial/unavailable.blade.php b/resources/views/dataspasial/unavailable.blade.php new file mode 100644 index 00000000..85f410d2 --- /dev/null +++ b/resources/views/dataspasial/unavailable.blade.php @@ -0,0 +1,20 @@ +{{-- + Pesan "data sementara tidak tersedia" (FR-014). + + WAJIB generik: tanpa detail teknis, nama endpoint, kode status HTTP, + atau jejak_exception. Detail tersebut hanya untuk log backend. +--}} + diff --git a/routes/web.php b/routes/web.php index a0adc458..08cb06a1 100644 --- a/routes/web.php +++ b/routes/web.php @@ -26,6 +26,11 @@ use App\Http\Controllers\DataPresisiPendidikanController; use App\Http\Controllers\DataPresisiSandangController; use App\Http\Controllers\DataPresisiSeniBudayaController; +use App\Http\Controllers\DataSpasial\DasborDataSpasialController; +use App\Http\Controllers\DataSpasial\ImporDataController; +use App\Http\Controllers\DataSpasial\MonitoringController; +use App\Http\Controllers\DataSpasial\PetaGabunganController; +use App\Http\Controllers\DataSpasial\SkemaDataSpasialController; use App\Http\Controllers\DDKPanganController; use App\Http\Controllers\DepartmentController; use App\Http\Controllers\DesaController; @@ -467,6 +472,42 @@ Route::post('post', [ApiProxyController::class, 'post'])->name('api-proxy.post'); Route::get('clear-cache', [ApiProxyController::class, 'clearCache'])->name('api-proxy.clear-cache'); }); + + // DataSpasial — seluruh data `ds_*` dibaca/tulis lewat API Satu Data + // melalui App\Services\DataSpasialApiService (FR-002). Kontrol akses + // ditegakkan server-side lewat middleware permission, bukan sekadar + // menyembunyikan menu (FR-013). + Route::prefix('dataspasial')->group(function () { + Route::get('skema', [SkemaDataSpasialController::class, 'index']) + ->middleware('permission:dataspasial-skema-read') + ->name('dataspasial.skema'); + + // KAB-02 — endpoint tulis editor skema (satu permintaan untuk sektor + isian, + // Q18/Q19). Otorisasi ditegakkan server-side lewat middleware permission (FR-001). + Route::post('skema/draf', [SkemaDataSpasialController::class, 'storeDraf']) + ->middleware('permission:dataspasial-skema-write') + ->name('dataspasial.skema.draf.store'); + + Route::post('skema/terbit', [SkemaDataSpasialController::class, 'terbit']) + ->middleware('permission:dataspasial-skema-write') + ->name('dataspasial.skema.terbit'); + + Route::get('peta', [PetaGabunganController::class, 'index']) + ->middleware('permission:dataspasial-peta-read') + ->name('dataspasial.peta'); + + Route::get('dasbor', [DasborDataSpasialController::class, 'index']) + ->middleware('permission:dataspasial-dasbor-read') + ->name('dataspasial.dasbor'); + + Route::get('impor', [ImporDataController::class, 'index']) + ->middleware('permission:dataspasial-impor-read') + ->name('dataspasial.impor'); + + Route::get('monitoring', [MonitoringController::class, 'index']) + ->middleware('permission:dataspasial-monitoring-read') + ->name('dataspasial.monitoring'); + }); }); // 2FA Challenge Routes (tanpa middleware 2fa untuk menghindari loop) diff --git a/tests/Browser/SmokeDataSpasialMenuTest.php b/tests/Browser/SmokeDataSpasialMenuTest.php new file mode 100644 index 00000000..8b041298 --- /dev/null +++ b/tests/Browser/SmokeDataSpasialMenuTest.php @@ -0,0 +1,94 @@ +user = SessionState::loginAdminUser(); +}); + +afterEach(function () { + SessionState::clear(); +}); + +/** + * @return array + */ +function dataspasialMenuLinks(): array +{ + return [ + 'Skema DataSpasial' => '/dataspasial/skema', + 'Peta Gabungan' => '/dataspasial/peta', + 'Dasbor DataSpasial' => '/dataspasial/dasbor', + 'Impor Data' => '/dataspasial/impor', + 'Monitoring' => '/dataspasial/monitoring', + ]; +} + +it('menampilkan grup menu DataSpasial beserta lima submenu', function () { + $page = SessionState::loginAndNavigate($this->user, '/dasbor') + ->assertPathIs('/dasbor'); + + $page->assertSee('DataSpasial'); + + // Submenu AdminLte dirender tersembunyi sampai grup diklik; karena itu + // keberadaan link diperiksa pada DOM, bukan visibilitasnya. + foreach (dataspasialMenuLinks() as $label => $path) { + $page->assertScript( + 'Array.from(document.querySelectorAll(".nav-sidebar a")) + .some(a => a.textContent.trim() === '.json_encode($label).' + && a.getAttribute("href").includes('.json_encode($path).'))', + true + ); + } + + ScreenshotHelper::saveIfEnabled($page, 'dataspasial-menu'); +}); + +it('membuka setiap halaman DataSpasial tanpa halaman error', function (string $path, string $title) { + $page = SessionState::loginAndNavigate($this->user, $path); + + $page->assertPathIs($path) + ->assertSee($title); + + ScreenshotHelper::saveIfEnabled($page, 'dataspasial'.str_replace('/', '-', $path)); +})->with([ + ['/dataspasial/skema', 'Skema DataSpasial'], + ['/dataspasial/peta', 'Peta Gabungan'], + ['/dataspasial/dasbor', 'Dasbor DataSpasial'], + ['/dataspasial/impor', 'Impor Data'], + ['/dataspasial/monitoring', 'Monitoring'], +]); + +it('menampilkan pesan data sementara tidak tersedia alih-alih halaman error', function () { + $page = SessionState::loginAndNavigate($this->user, '/dataspasial/peta'); + + // API tidak hidup pada lingkungan uji → harus jatuh ke kondisi unavailable. + $page->assertSee('Data sementara tidak tersedia') + ->assertDontSee('Whoops') + ->assertDontSee('Stack trace'); + + ScreenshotHelper::saveIfEnabled($page, 'dataspasial-unavailable'); +}); + +it('mempertahankan kerangka halaman dan navigasi saat data tidak tersedia', function () { + $page = SessionState::loginAndNavigate($this->user, '/dataspasial/monitoring'); + + // Layout admin tetap utuh: navigasi dan judul halaman masih terlihat. + $page->assertSee('Monitoring') + ->assertSee('DataSpasial') + ->assertSee('Peta Gabungan'); + + ScreenshotHelper::saveIfEnabled($page, 'dataspasial-unavailable-frame'); +}); diff --git a/tests/Browser/SmokeSkemaEditorTest.php b/tests/Browser/SmokeSkemaEditorTest.php new file mode 100644 index 00000000..088d369d --- /dev/null +++ b/tests/Browser/SmokeSkemaEditorTest.php @@ -0,0 +1,297 @@ +user = SessionState::loginAdminUser(); +}); + +afterEach(function () { + SessionState::clear(); +}); + +it('membuka halaman editor skema tanpa error', function () { + $page = SessionState::loginAndNavigate($this->user, '/dataspasial/skema') + ->assertPathIs('/dataspasial/skema') + ->assertSee('Skema DataSpasial'); + + ScreenshotHelper::saveIfEnabled($page, 'skema-editor-halaman'); +}); + +it('menampilkan konten editor atau pesan data sementara tidak tersedia', function () { + $page = SessionState::loginAndNavigate($this->user, '/dataspasial/skema') + ->assertPathIs('/dataspasial/skema'); + + // Dua keadaan yang boleh terjadi: editor terisi (API hidup) atau pesan + // "data sementara tidak tersedia" (API mati). Keduanya WAJIB bukan error. + $page->assertScript( + "(function () { + const isi = document.body.innerText; + const editor = document.querySelector('[data-status-editor]'); + const kosong = document.querySelector('[data-testid=\"dataspasial-empty\"]'); + const takTersedia = document.querySelector('[data-testid=\"dataspasial-unavailable\"]'); + return Boolean(editor || kosong || takTersedia) + && isi.indexOf('Stack trace') === -1 + && isi.indexOf('Whoops') === -1; + })()", + true + ); + + ScreenshotHelper::saveIfEnabled($page, 'skema-editor-keadaan'); +}); + +it('menampilkan tombol Terbitkan dalam keadaan nonaktif', function () { + $page = SessionState::loginAndNavigate($this->user, '/dataspasial/skema') + ->assertPathIs('/dataspasial/skema'); + + $page->assertScript( + "(function () { + const tombol = document.getElementById('btn-terbitkan'); + if (!tombol) { + // Editor tidak dirender saat sumber tidak tersedia (FR-005). + return Boolean(document.querySelector('[data-testid=\"dataspasial-unavailable\"]')); + } + return tombol.disabled === true; + })()", + true + ); + + ScreenshotHelper::saveIfEnabled($page, 'skema-editor-terbitkan-nonaktif'); +}); + +it('tidak pernah memanggil endpoint terbitan', function () { + $page = SessionState::loginAndNavigate($this->user, '/dataspasial/skema') + ->assertPathIs('/dataspasial/skema'); + + $page->assertScript( + "(function () { + const isi = document.documentElement.outerHTML; + return isi.indexOf('/terbit') === -1; + })()", + true + ); +}); + +it('menyimpan seluruh halaman tanpa error javascript', function () { + $page = SessionState::loginAndNavigate($this->user, '/dataspasial/skema') + ->assertPathIs('/dataspasial/skema'); + + // Kegagalan ONLY yang relevan: skrip editor gagal dimuat. Kesalahan + // jaringan dari aset lain tidak boleh menggagalkan test ini. + $page->assertScript( + "(function () { + return typeof window.__schemaEditorAssetMissing !== 'boolean'; + })()", + true + ); + + ScreenshotHelper::saveIfEnabled($page, 'skema-editor-akhir'); +}); + +it('tidak menampilkan stack trace maupun halaman error', function () { + $page = SessionState::loginAndNavigate($this->user, '/dataspasial/skema') + ->assertPathIs('/dataspasial/skema'); + + $isi = $page->text('body'); + + expect($isi) + ->not->toContain('Stack trace') + ->not->toContain('Whoops, looks like something went wrong') + ->not->toContain('SQLSTATE') + ->not->toContain('/var/www/'); +}); +// --------------------------------------------------------------------------- +// Q24/Q25 — FR-008c/e — alur tombol "Ubah" → "Batal" pada skema `terbit` +// +// Tanpa API Satu Data yang hidup, editor berada dalam keadaan +// "data sementara tidak tersedia" sehingga tombol Ubah tidak dirender. Test +// ini karena itu memverifikasi dua hal yang tetap berlaku di kedua keadaan: +// (1) tidak ada tombol Ubah pada keadaan non-terbit, dan (2) bila tombol Ubah +// ada, kliknya TIDAK menghasilkan permintaan jaringan apa pun (FR-008c). +// --------------------------------------------------------------------------- + +it('menampilkan tombol Ubah hanya pada skema terbit', function () { + $page = SessionState::loginAndNavigate($this->user, '/dataspasial/skema') + ->assertPathIs('/dataspasial/skema'); + + $page->assertScript( + "(function () { + const akar = document.querySelector('[data-status-editor]'); + if (!akar) { + // Sumber data tidak tersedia — tombol Ubah memang tidak boleh ada. + return document.getElementById('btn-ubah') === null; + } + if (akar.dataset.statusEditor === 'terbit') { + return document.getElementById('btn-ubah') !== null; + } + return document.getElementById('btn-ubah') === null; + })()", + true + ); + + ScreenshotHelper::saveIfEnabled($page, 'skema-editor-tombol-ubah'); +}); + +it('tidak mengirim permintaan apa pun ketika tombol Ubah diklik', function () { + $page = SessionState::loginAndNavigate($this->user, '/dataspasial/skema') + ->assertPathIs('/dataspasial/skema'); + + $page->assertScript( + "(function () { + const tombol = document.getElementById('btn-ubah'); + if (!tombol) { + return true; // tidak ada tombol pada keadaan ini + } + + // Hitung permintaan jaringan yang dibuat library fetcher browser. + let jumlah = 0; + const asli = window.fetch; + window.fetch = function () { jumlah += 1; return asli.apply(this, arguments); }; + + tombol.click(); + + window.fetch = asli; + + return jumlah === 0; + })()", + true + ); + + ScreenshotHelper::saveIfEnabled($page, 'skema-editor-ubah-tanpa-request'); +}); + +// --------------------------------------------------------------------------- +// Q26/Q27 — FR-008f/g — mode "sektor baru" + SweetAlert2 termuat +// --------------------------------------------------------------------------- + +it('memuat SweetAlert2 untuk seluruh pesan pada halaman editor', function () { + $page = SessionState::loginAndNavigate($this->user, '/dataspasial/skema') + ->assertPathIs('/dataspasial/skema'); + + // FR-008g/Q27 — `window.Swal` wajib tersedia; pesan TIDAK lagi memakai + // window.alert/confirm/prompt. Bila CDN terblokir, halamaneditor akan + // memakai fallback sehingga tidak fatal. + $page->assertScript( + "(function () { + const tersedia = typeof window.Swal !== 'undefined'; + if (!tersedia) { + return typeof window.__swalCdnGagal !== 'undefined'; + } + return typeof window.Swal.fire === 'function'; + })()", + true + ); + + ScreenshotHelper::saveIfEnabled($page, 'skema-editor-sweetalert'); +}); + +it('menyediakan tombol Tambah Sektor Baru tanpa formulir terpisah', function () { + $page = SessionState::loginAndNavigate($this->user, '/dataspasial/skema') + ->assertPathIs('/dataspasial/skema'); + + $page->assertScript( + "(function () { + const tombol = document.getElementById('btn-tambah-sektor'); + if (!tombol) { + // Editor tidak dirender saat sumber data tidak tersedia (FR-005). + return Boolean(document.querySelector('[data-testid=\"dataspasial-unavailable\"]')); + } + return document.getElementById('form-tambah-sektor') === null; + })()", + true + ); + + ScreenshotHelper::saveIfEnabled($page, 'skema-editor-tambah-sektor'); +}); + +// --------------------------------------------------------------------------- +// Regresi — tombol "Tambah Isian Baru" harus berfungsi pada mode penyuntingan +// (terbit → Ubah, dan mode "sektor baru"). Template baris kosong WAJIB selalu +// ada di DOM; bila tidak, handler bail-out diam-diam sehingga tombol tidak +// berefek (FR-008c/FR-008f). +// --------------------------------------------------------------------------- + +it('menyediakan template baris isian agar tombol tambah isian dapat bekerja', function () { + $page = SessionState::loginAndNavigate($this->user, '/dataspasial/skema') + ->assertPathIs('/dataspasial/skema'); + + $page->assertScript( + "(function () { + // Saat editor tidak dirender (sumber data tidak tersedia) tidak ada + // yang perlu diverifikasi — degradasi anggun (FR-005). + if (!document.getElementById('isi-tabel-isian')) { + return true; + } + + const template = document.getElementById('template-baris-isian'); + const tombol = document.getElementById('btn-tambah-isian'); + + return Boolean(template) && Boolean(tombol); + })()", + true + ); + + ScreenshotHelper::saveIfEnabled($page, 'skema-editor-template-baris'); +}); + +// --------------------------------------------------------------------------- +// Regresi — klik "Tambah Sektor Baru" tanpa pernah memilih sektor harus +// memunculkan kolom Nama sektor (FR-008f/Q26). +// --------------------------------------------------------------------------- + +it('memunculkan kolom nama sektor saat tambah sektor baru tanpa memilih sektor', function () { + $page = SessionState::loginAndNavigate($this->user, '/dataspasial/skema') + ->assertPathIs('/dataspasial/skema'); + + $page->assertScript( + "(function () { + const tombol = document.getElementById('btn-tambah-sektor'); + + if (!tombol) { + return true; // editor tidak dirender (sumber data tidak tersedia) + } + + // Keadaan awal: kolom ada di DOM tetapi tersembunyi. + const wadah = document.getElementById('wadah-input-sektor-nama'); + const input = document.getElementById('input-sektor-nama-editor'); + + if (!wadah || !input) { + return false; + } + + const tersembunyiAwal = wadah.classList.contains('d-none'); + + // Klik tombol tambah sektor baru - kolom harus muncul & aktif. + tombol.click(); + + return tersembunyiAwal + && !wadah.classList.contains('d-none') + && input.disabled === false + && input.value === ''; + })()", + true + ); + + ScreenshotHelper::saveIfEnabled($page, 'skema-editor-mode-sektor-baru'); +}); diff --git a/tests/Feature/DataSpasial/DataSpasialResultTest.php b/tests/Feature/DataSpasial/DataSpasialResultTest.php new file mode 100644 index 00000000..bfce425b --- /dev/null +++ b/tests/Feature/DataSpasial/DataSpasialResultTest.php @@ -0,0 +1,127 @@ + '1', 'type' => 'ds_sektor', 'attributes' => ['kode' => 'pendidikan']], + ['id' => '2', 'type' => 'ds_sektor', 'attributes' => ['kode' => 'kesehatan']], + ]; + $meta = ['pagination' => ['total' => 2, 'count' => 2, 'per_page' => 10, 'current_page' => 1, 'total_pages' => 1]]; + + $result = DataSpasialResult::data($items, $meta); + + expect($result->status)->toBeInstanceOf(DataSpasialStatus::class) + ->and($result->status->value)->toBe('data') + ->and($result->isData())->toBeTrue() + ->and($result->isEmpty())->toBeFalse() + ->and($result->isUnavailable())->toBeFalse() + ->and($result->reason)->toBeNull() + ->and($result->httpStatus)->toBe(200) + ->and($result->data)->toBe($items) + ->and($result->count())->toBe(2) + ->and($result->first())->toBe($items[0]) + ->and($result->pagination())->toBe($meta['pagination']); +}); + +it('menandai hasil kosong tanpa dianggap gagal', function () { + $meta = ['pagination' => ['total' => 0, 'count' => 0]]; + + $result = DataSpasialResult::empty($meta); + + expect($result->status)->toBeInstanceOf(DataSpasialStatus::class) + ->and($result->status->value)->toBe('empty') + ->and($result->isEmpty())->toBeTrue() + ->and($result->isData())->toBeFalse() + ->and($result->isUnavailable())->toBeFalse() + ->and($result->reason)->toBeNull() + ->and($result->data)->toBe([]) + ->and($result->count())->toBe(0) + ->and($result->first())->toBeNull() + ->and($result->pagination())->toBe($meta['pagination']); +}); + +it('menandai hasil tidak tersedia dengan penyebab', function () { + $result = DataSpasialResult::unavailable( + DataSpasialReason::fromValue(DataSpasialReason::ModuleNotInstalled), + 503, + 'Modul DataSpasial belum terpasang atau versinya tidak cocok' + ); + + expect($result->status)->toBeInstanceOf(DataSpasialStatus::class) + ->and($result->status->value)->toBe('unavailable') + ->and($result->isUnavailable())->toBeTrue() + ->and($result->isData())->toBeFalse() + ->and($result->isEmpty())->toBeFalse() + ->and($result->reason)->toBeInstanceOf(DataSpasialReason::class) + ->and($result->reason->value)->toBe('module_not_installed') + ->and($result->httpStatus)->toBe(503) + ->and($result->message)->toBe('Modul DataSpasial belum terpasang atau versinya tidak cocok') + ->and($result->data)->toBe([]) + ->and($result->count())->toBe(0) + ->and($result->first())->toBeNull() + ->and($result->pagination())->toBe([]); +}); + +it('menyediakan pesan generik untuk pengguna dan tidak membocorkan pesan teknis', function () { + $result = DataSpasialResult::unavailable( + DataSpasialReason::fromValue(DataSpasialReason::Timeout), + 0, + 'cURL error 28: Operation timed out after 10001 milliseconds' + ); + + expect($result->userMessage())->toBe('Data sementara tidak tersedia. Silakan coba lagi nanti.') + ->and($result->userMessage())->not->toContain('cURL error'); +}); + +it('memberikan pesan generik yang sama untuk semua penyebab', function () { + $messages = []; + + foreach (DataSpasialReason::getValues() as $reason) { + $messages[] = DataSpasialResult::unavailable(DataSpasialReason::fromValue($reason))->userMessage(); + } + + expect($messages)->toHaveCount(count(DataSpasialReason::getValues())) + ->and(array_unique($messages))->toHaveCount(1); +}); + +it('mengembalikan pagination kosong bila meta tidak tersedia', function () { + expect(DataSpasialResult::data([['id' => '1']])->pagination())->toBe([]) + ->and(DataSpasialResult::empty()->pagination())->toBe([]); +}); + +it('menolak data kosong pada status data', function () { + DataSpasialResult::data([]); +})->throws(InvalidArgumentException::class); + +it('menolak alasan pada status data dan kosong', function () { + expect(fn () => DataSpasialResult::data([['id' => '1']], [], DataSpasialReason::fromValue(DataSpasialReason::Timeout))) + ->toThrow(InvalidArgumentException::class) + ->and(fn () => DataSpasialResult::empty([], DataSpasialReason::fromValue(DataSpasialReason::Timeout))) + ->toThrow(InvalidArgumentException::class); +}); + +it('menolak hasil tidak tersedia tanpa alasan', function () { + $constructor = (new ReflectionClass(DataSpasialResult::class))->getConstructor(); + $constructor->setAccessible(true); + + expect(fn () => $constructor->invokeArgs( + (new ReflectionClass(DataSpasialResult::class))->newInstanceWithoutConstructor(), + [ + DataSpasialStatus::fromValue(DataSpasialStatus::Unavailable), + [], + [], + null, + 0, + null, + ] + ))->toThrow(InvalidArgumentException::class); +}); + +it('menyembunyikan constructor agar hanya dibangun lewat named constructor', function () { + expect((new ReflectionClass(DataSpasialResult::class))->getConstructor()->isPrivate())->toBeTrue(); +}); diff --git a/tests/Feature/DataSpasial/DataSpasialRoleMigrationTest.php b/tests/Feature/DataSpasial/DataSpasialRoleMigrationTest.php new file mode 100644 index 00000000..c7f913d6 --- /dev/null +++ b/tests/Feature/DataSpasial/DataSpasialRoleMigrationTest.php @@ -0,0 +1,340 @@ +up(); +} + +beforeEach(function () { + config([ + 'app.databaseGabunganUrl' => 'https://api.example.test', + 'dataspasial.cache_ttl' => 300, + ]); + + Cache::flush(); + + // Pastikan permission dasar hasil `admin:menu-update` sudah ada. + setPermissionsTeamId(Team::where('name', 'administrator')->first()->id); + Artisan::call('admin:menu-update'); + + runDataSpasialRolesMigration(); +}); + +function permissionsOf(string $teamName): array +{ + $team = Team::where('name', $teamName)->firstOrFail(); + setPermissionsTeamId($team->id); + + $role = Role::where('team_id', $team->id)->where('name', $teamName)->firstOrFail(); + + return $role->permissions->pluck('name')->sort()->values()->all(); +} + +/** + * @return array + */ +function teamMenu(string $teamName): array +{ + return Team::where('name', $teamName)->firstOrFail()->menu ?? []; +} + +/** + * @param array $abilities + * @return array + */ +function abilities(string $menu, array $abilities): array +{ + return array_map(fn (string $a) => "dataspasial-{$menu}-{$a}", $abilities); +} + +// --------------------------------------------------------------------------- +// T023 — Ketiga peran tersedia beserta izin +// --------------------------------------------------------------------------- + +it('menyediakan tiga peran DataSpasial', function (string $teamName) { + $team = Team::where('name', $teamName)->first(); + + expect($team)->not->toBeNull(); + + setPermissionsTeamId($team->id); + + $role = Role::where('team_id', $team->id)->where('name', $teamName)->first(); + + expect($role)->not->toBeNull() + ->and($role->guard_name)->toBe('web') + ->and($role->permissions->isNotEmpty())->toBeTrue(); +})->with(['admin skema', 'operator impor', 'analis opd']); + +it('memberikan permission menu parent pada setiap peran', function (string $teamName) { + expect(permissionsOf($teamName))->toContain('dataspasial'); +})->with(['admin skema', 'operator impor', 'analis opd']); + +it('memberikan akses penuh menu DataSpasial kepada role administrator', function () { + $team = Team::where('name', 'administrator')->firstOrFail(); + setPermissionsTeamId($team->id); + + $role = Role::where('team_id', $team->id)->where('name', 'administrator')->firstOrFail(); + $permissions = $role->permissions->pluck('name')->all(); + + // Permission diturunkan `admin:menu-update` dari App\Enums\Modul::Menu + // (grup DataSpasial + tiap submenu × ability). + foreach ([ + 'dataspasial-read', 'dataspasial-write', + 'dataspasial-skema-read', 'dataspasial-skema-write', 'dataspasial-skema-edit', 'dataspasial-skema-delete', + 'dataspasial-peta-read', + 'dataspasial-dasbor-read', + 'dataspasial-impor-read', 'dataspasial-impor-write', + 'dataspasial-monitoring-read', + ] as $permission) { + expect($permissions)->toContain($permission); + } + + $submenuTexts = []; + foreach (teamMenu('administrator') as $group) { + if (($group['text'] ?? null) === 'DataSpasial') { + $submenuTexts = array_column($group['submenu'] ?? [], 'text'); + } + } + + expect($submenuTexts)->toContain( + 'Skema DataSpasial', + 'Peta Gabungan', + 'Dasbor DataSpasial', + 'Impor Data', + 'Monitoring', + ); +}); + +// --------------------------------------------------------------------------- +// T036 — Matriks izin (FR-011) +// --------------------------------------------------------------------------- + +it('memberikan Admin Skema hak mengelola skema dan membaca halaman lain', function () { + $permissions = permissionsOf('admin skema'); + + foreach (abilities('skema', ['read', 'write', 'edit', 'delete']) as $permission) { + expect($permissions)->toContain($permission); + } + + foreach (abilities('peta', ['read']) + abilities('dasbor', ['read']) + abilities('monitoring', ['read']) as $permission) { + expect($permissions)->toContain($permission); + } + + expect($permissions)->not->toContain('dataspasial-impor-write') + ->and($permissions)->not->toContain('dataspasial-impor-read'); +}); + +it('memberikan Operator Impor hak impor tetapi bukan mengelola skema', function () { + $permissions = permissionsOf('operator impor'); + + expect($permissions)->toContain('dataspasial-impor-read') + ->and($permissions)->toContain('dataspasial-impor-write') + ->and($permissions)->not->toContain('dataspasial-skema-write') + ->and($permissions)->not->toContain('dataspasial-skema-edit') + ->and($permissions)->not->toContain('dataspasial-skema-delete'); +}); + +it('memberikan Analis OPD hanya hak baca tanpa kemampuan tulis', function () { + $permissions = permissionsOf('analis opd'); + + foreach (abilities('peta', ['read']) + abilities('dasbor', ['read']) + abilities('monitoring', ['read']) as $permission) { + expect($permissions)->toContain($permission); + } + + foreach ($permissions as $permission) { + expect($permission)->not->toEndWith('-write') + ->and($permission)->not->toEndWith('-edit') + ->and($permission)->not->toEndWith('-delete'); + } +}); + +it('menyimpan menu grup pada team sesuai matriks izin', function () { + $adminSkemaTexts = array_column(teamMenu('admin skema')[0]['submenu'] ?? [], 'text'); + $operatorImporTexts = array_column(teamMenu('operator impor')[0]['submenu'] ?? [], 'text'); + $analisTexts = array_column(teamMenu('analis opd')[0]['submenu'] ?? [], 'text'); + + expect($adminSkemaTexts)->toContain('Skema DataSpasial') + ->and($adminSkemaTexts)->not->toContain('Impor Data') + ->and($operatorImporTexts)->toContain('Impor Data') + ->and($operatorImporTexts)->not->toContain('Skema DataSpasial') + ->and($analisTexts)->not->toContain('Skema DataSpasial') + ->and($analisTexts)->not->toContain('Impor Data'); +}); + +it('menyimpan flag hak akses pada menu team agar tercentang di Pengaturan Grup', function () { + // Halaman Pengaturan › Grup membaca flag "{permission}-read|write|edit|delete" + // dari team.menu (lihat resources/views/group/edit_js.blade.php). Tanpa flag + // ini kotak centang tidak tercentang. + $adminGroup = null; + foreach (teamMenu('admin skema') as $group) { + if (($group['text'] ?? null) === 'DataSpasial') { + $adminGroup = $group; + } + } + + expect($adminGroup)->not->toBeNull() + ->and($adminGroup['dataspasial-read'])->toBeTrue() + ->and($adminGroup['selected'])->toBeTrue(); + + $skema = collect($adminGroup['submenu'])->firstWhere('permission', 'dataspasial-skema'); + + expect($skema)->not->toBeNull() + ->and($skema['dataspasial-skema-read'])->toBeTrue() + ->and($skema['dataspasial-skema-write'])->toBeTrue() + ->and($skema['dataspasial-skema-edit'])->toBeTrue() + ->and($skema['dataspasial-skema-delete'])->toBeTrue() + ->and($skema['selected'])->toBeTrue(); + + // Analis OPD: read saja pada Peta Gabungan. + $analisGroup = null; + foreach (teamMenu('analis opd') as $group) { + if (($group['text'] ?? null) === 'DataSpasial') { + $analisGroup = $group; + } + } + + $peta = collect($analisGroup['submenu'])->firstWhere('permission', 'dataspasial-peta'); + + expect($peta['dataspasial-peta-read'])->toBeTrue() + ->and($peta['dataspasial-peta-write'])->toBeFalse() + ->and($peta['dataspasial-peta-edit'])->toBeFalse() + ->and($peta['dataspasial-peta-delete'])->toBeFalse(); +}); + +it('memperbaiki menu team lama yang belum memuat flag hak akses', function () { + $team = Team::where('name', 'admin skema')->firstOrFail(); + + // Simulasikan menu versi lama: grup & submenu tanpa flag ability. + $legacy = [[ + 'text' => 'DataSpasial', + 'icon' => 'fas fa-map', + 'permission' => 'dataspasial', + 'submenu' => [ + ['text' => 'Skema DataSpasial', 'url' => 'dataspasial/skema', 'permission' => 'dataspasial-skema'], + ], + ]]; + $team->update(['menu' => $legacy]); + + runDataSpasialRolesMigration(); + + $group = null; + foreach (teamMenu('admin skema') as $item) { + if (($item['text'] ?? null) === 'DataSpasial') { + $group = $item; + } + } + + expect($group['dataspasial-read'])->toBeTrue() + ->and(collect($group['submenu'])->firstWhere('permission', 'dataspasial-skema')['dataspasial-skema-read'])->toBeTrue(); +}); + +// --------------------------------------------------------------------------- +// T037 — Idempotensi +// --------------------------------------------------------------------------- + +it('aman dijalankan berulang tanpa menggandakan data', function () { + $teamsBefore = Team::whereIn('name', ['admin skema', 'operator impor', 'analis opd'])->count(); + + $rolesBefore = 0; + foreach (['admin skema', 'operator impor', 'analis opd'] as $name) { + $team = Team::where('name', $name)->first(); + setPermissionsTeamId($team->id); + $rolesBefore += Role::where('team_id', $team->id)->count(); + } + + runDataSpasialRolesMigration(); + runDataSpasialRolesMigration(); + + expect(Team::whereIn('name', ['admin skema', 'operator impor', 'analis opd'])->count())->toBe($teamsBefore); + + $rolesAfter = 0; + foreach (['admin skema', 'operator impor', 'analis opd'] as $name) { + $team = Team::where('name', $name)->first(); + setPermissionsTeamId($team->id); + $rolesAfter += Role::where('team_id', $team->id)->count(); + } + + expect($rolesAfter)->toBe($rolesBefore); +}); + +it('menjaga izin yang sudah ada pada team ketika dijalankan ulang', function () { + $team = Team::where('name', 'analis opd')->firstOrFail(); + setPermissionsTeamId($team->id); + + $role = Role::where('team_id', $team->id)->where('name', 'analis opd')->firstOrFail(); + $role->givePermissionTo(Permission::findOrCreate('dataspasial-peta-write', 'web')); + + runDataSpasialRolesMigration(); + + expect(permissionsOf('analis opd'))->toContain('dataspasial-peta-write'); +}); + +it('membuat ulang permission DataSpasial bila hilang', function () { + Permission::where('name', 'like', 'dataspasial%')->delete(); + + runDataSpasialRolesMigration(); + + expect(permissionsOf('admin skema'))->toContain('dataspasial-skema-read'); +}); + +// --------------------------------------------------------------------------- +// T038 — Penugasan pengguna +// --------------------------------------------------------------------------- + +it('menegakkan kontrol akses setelah pengguna ditugaskan ke peran', function () { + Http::fake([ + '*/api/v1/config/*' => Http::response(['data' => []], 200), + '*' => Http::response([ + 'data' => [['type' => 'ds_sektor', 'id' => '1', 'attributes' => []]], + 'meta' => ['pagination' => ['total' => 1, 'count' => 1]], + 'links' => [], + ], 200), + ]); + + $team = Team::where('name', 'analis opd')->firstOrFail(); + setPermissionsTeamId($team->id); + + $user = User::factory()->create(); + $user->teams()->attach($team->id, ['id_user' => $user->id, 'id_team' => $team->id]); + $user->unsetRelation('team')->load('team'); + $user->assignRole(Role::where('team_id', $team->id)->where('name', 'analis opd')->firstOrFail()); + + $this->actingAs($user, 'web'); + + $this->get(route('dataspasial.peta'))->assertOk(); + $this->get(route('dataspasial.dasbor'))->assertOk(); + $this->get(route('dataspasial.monitoring'))->assertOk(); + $this->get(route('dataspasial.skema'))->assertForbidden(); + $this->get(route('dataspasial.impor'))->assertForbidden(); +}); + +it('memastikan grup DataSpasial ada di App\Enums\Modul::Menu sebagai sumber kebenaran', function () { + $texts = array_column(Modul::Menu, 'text'); + + expect($texts)->toContain('DataSpasial'); +}); diff --git a/tests/Feature/DataSpasial/DataSpasialUnavailableTest.php b/tests/Feature/DataSpasial/DataSpasialUnavailableTest.php new file mode 100644 index 00000000..ff8e6c25 --- /dev/null +++ b/tests/Feature/DataSpasial/DataSpasialUnavailableTest.php @@ -0,0 +1,211 @@ + 'https://api.example.test', + 'dataspasial.cache_ttl' => 300, + ]); + + Setting::updateOrCreate( + ['key' => 'database_gabungan_api_key'], + ['name' => 'API Key', 'value' => 'token-uji'] + ); + + Cache::flush(); + + Http::fake([ + '*/api/v1/config/*' => Http::response(['data' => []], 200), + ]); + + $team = Team::firstOrCreate(['name' => 'analis opd 503'], ['menu' => []]); + setPermissionsTeamId($team->id); + + $role = Role::firstOrCreate(['name' => 'analis opd 503', 'team_id' => $team->id, 'guard_name' => 'web']); + $role->syncPermissions([ + Permission::findOrCreate('dataspasial-peta-read', 'web'), + Permission::findOrCreate('dataspasial-dasbor-read', 'web'), + Permission::findOrCreate('dataspasial-monitoring-read', 'web'), + ]); + + $user = User::factory()->create(); + $user->teams()->attach($team->id, ['id_user' => $user->id, 'id_team' => $team->id]); + $user->unsetRelation('team')->load('team'); + $user->assignRole($role); + + $this->actingAs($user, 'web'); +}); + +/** + * @return array + */ +function dataspasialRoutes(): array +{ + return ['dataspasial.peta', 'dataspasial.dasbor', 'dataspasial.monitoring']; +} + +// --------------------------------------------------------------------------- +// T043 — 503 modul belum terpasang +// --------------------------------------------------------------------------- + +it('menampilkan pesan data sementara tidak tersedia saat modul belum terpasang', function (string $route) { + Http::fake([ + '*/api/v1/config/*' => Http::response(['data' => []], 200), + '*' => Http::response([ + 'success' => false, + 'message' => 'Modul DataSpasial belum terpasang atau versinya tidak cocok', + ], 503), + ]); + + $response = $this->get(route($route)); + + $response->assertOk() + ->assertSee('Data sementara tidak tersedia') + ->assertSee('dataspasial-unavailable', false); +})->with(dataspasialRoutes()); + +// --------------------------------------------------------------------------- +// T044 — Timeout +// --------------------------------------------------------------------------- + +it('menampilkan pesan yang sama saat API melewati batas waktu', function (string $route) { + Http::fake([ + '*/api/v1/config/*' => Http::response(['data' => []], 200), + '*' => fn () => throw new ConnectionException('cURL error 28: Operation timed out'), + ]); + + $this->get(route($route)) + ->assertOk() + ->assertSee('Data sementara tidak tersedia'); +})->with(dataspasialRoutes()); + +// --------------------------------------------------------------------------- +// T045 — Konfigurasi tidak lengkap +// --------------------------------------------------------------------------- + +it('menampilkan pesan yang sama saat konfigurasi database gabungan tidak lengkap', function (string $route) { + Setting::where('key', 'database_gabungan_api_key')->delete(); + + $this->get(route($route)) + ->assertOk() + ->assertSee('Data sementara tidak tersedia'); +})->with(dataspasialRoutes()); + +// --------------------------------------------------------------------------- +// T046 — Empty state (FR-015) +// --------------------------------------------------------------------------- + +it('menampilkan keadaan kosong dan bukan pesan gagal saat API sukses tanpa data', function (string $route) { + Http::fake([ + '*/api/v1/config/*' => Http::response(['data' => []], 200), + '*' => Http::response([ + 'data' => [], + 'meta' => ['pagination' => ['total' => 0, 'count' => 0, 'per_page' => 10, 'current_page' => 1, 'total_pages' => 1]], + 'links' => [], + ], 200), + ]); + + $response = $this->get(route($route)); + + $response->assertOk() + ->assertSee('Belum ada data') + ->assertSee('dataspasial-empty', false) + ->assertDontSee('Data sementara tidak tersedia'); +})->with(dataspasialRoutes()); + +it('menampilkan isi halaman saat API sukses berisi data', function () { + Http::fake([ + '*/api/v1/config/*' => Http::response(['data' => []], 200), + '*' => Http::response([ + 'data' => [['type' => 'ds_adopsi', 'id' => '1', 'attributes' => ['status_adopsi' => 'terbaru', 'perkuliahan' => '2025-06']]], + 'meta' => ['pagination' => ['total' => 1, 'count' => 1]], + 'links' => [], + ], 200), + ]); + + $this->get(route('dataspasial.monitoring')) + ->assertOk() + ->assertSee('Status Adopsi Skema per Desa') + ->assertSee('terbaru') + ->assertDontSee('Data sementara tidak tersedia') + ->assertDontSee('Belum ada data'); +}); + +// --------------------------------------------------------------------------- +// T047 — Kerangka halaman tetap utuh +// --------------------------------------------------------------------------- + +it('tetap menampilkan navigasi dan kerangka halaman saat API bermasalah', function () { + Http::fake([ + '*/api/v1/config/*' => Http::response(['data' => []], 200), + '*' => Http::response(['success' => false, 'message' => 'Modul DataSpasial belum terpasang'], 503), + ]); + + $content = $this->get(route('dataspasial.peta'))->assertOk()->getContent(); + + // Layout adminlte tetap dirender utuh: judul halaman, navigasi sidebar, + // dan elemen kerangka halaman tetap ada meski data tidak bisa diperoleh. + expect($content)->toContain('Peta Gabungan') + ->and($content)->toContain('logout') + ->and($content)->toContain('content-header') + ->and($content)->toContain('and($content)->toContain('Data sementara tidak tersedia'); +}); + +it('tidak pernah menampilkan stack trace atau jejak exception', function (string $route) { + Http::fake([ + '*/api/v1/config/*' => Http::response(['data' => []], 200), + '*' => fn () => throw new ConnectionException('cURL error 28: Operation timed out after 10001 ms with /var/www/secret/path.php'), + ]); + + $content = $this->get(route($route))->assertOk()->getContent(); + + expect($content) + ->not->toContain('cURL error') + ->not->toContain('/var/www/secret') + ->not->toContain('Stack trace') + ->not->toContain('#0 /'); +})->with(dataspasialRoutes()); + +// --------------------------------------------------------------------------- +// T048 — Log tanpa membocorkan detail ke pengguna +// --------------------------------------------------------------------------- + +it('tidak menampilkan pesan teknis API kepada pengguna tetapi mencatatnya di log', function () { + Log::spy(); + + Http::fake([ + '*/api/v1/config/*' => Http::response(['data' => []], 200), + '*' => Http::response([ + 'success' => false, + 'message' => 'Modul DataSpasial belum terpasang atau versinya tidak cocok', + ], 503), + ]); + + $content = $this->get(route('dataspasial.peta'))->assertOk()->getContent(); + + expect($content)->not->toContain('Modul DataSpasial belum terpasang'); + + Log::shouldHaveReceived('warning') + ->withArgs(fn (string $message, array $context = []) => str_contains($message, 'DataSpasial') + && ($context['reason'] ?? null) === 'module_not_installed'); +}); diff --git a/tests/Feature/DataSpasial/Fixtures.php b/tests/Feature/DataSpasial/Fixtures.php new file mode 100644 index 00000000..df0b90ba --- /dev/null +++ b/tests/Feature/DataSpasial/Fixtures.php @@ -0,0 +1,225 @@ +> $items + * @return array + */ +function fakeKoleksi(array $items): array +{ + return [ + 'data' => $items, + 'meta' => ['pagination' => [ + 'total' => count($items), + 'count' => count($items), + 'per_page' => 200, + 'current_page' => 1, + 'total_pages' => 1, + ]], + 'links' => [], + ]; +} + +/** + * Item sektor — bentuk nyata `GET /dataspasial/sektor`. + * + * @return array + */ +function fxSektor(string $id, string $kode, string $nama, int $jumlahVersi = 2, int $jumlahTerbit = 1): array +{ + return [ + 'type' => 'daftar sektor', + 'id' => $id, + 'attributes' => [ + 'kode' => $kode, + 'nama' => $nama, + 'ikon' => null, + 'deskripsi' => null, + 'urutan' => 0, + 'status' => 'aktif', + 'jumlah_versi_skema' => $jumlahVersi, + 'jumlah_versi_terbit' => $jumlahTerbit, + ], + ]; +} + +/** + * Entri riwayat versi — bentuk nyata `GET /dataspasial/skema/riwayat` (Q9). + * + * @return array + */ +function fxRiwayatVersi(string $id, int $sektorId, int $nomorVersi, string $status): array +{ + return [ + 'type' => 'riwayat versi skema', + 'id' => $id, + 'attributes' => [ + 'sektor_id' => $sektorId, + 'nomor_versi' => $nomorVersi, + 'status' => $status, + 'catatan_perubahan' => 'Draf uji.', + 'diterbitkan_pada' => $status === 'terbit' ? '2025-10-01T09:00:00+07:00' : null, + 'diterbitkan_oleh' => $status === 'terbit' ? 'admin@opengkab.go.id' : null, + ], + ]; +} + +/** + * Versi aktif beserta isian — bentuk nyata `GET /dataspasial/skema?sektor=` + * (K2: isian ikut di sini; K3: versi bersarang di `versi_skema`). + * + * @param array> $isian + * @return array + */ +function fxSkemaAktif( + string $id, + int $sektorId, + string $sektorKode, + string $sektorNama, + int $nomorVersi, + string $status, + array $isian = [] +): array { + return [ + 'type' => 'skema data spasial', + 'id' => $id, + 'attributes' => [ + 'sektor' => ['id' => $sektorId, 'kode' => $sektorKode, 'nama' => $sektorNama], + 'versi_skema' => [ + 'id' => (int) $id, + 'sektor_id' => $sektorId, + 'nomor_versi' => $nomorVersi, + 'status' => $status, + 'catatan_perubahan' => 'Draf uji.', + 'diterbitkan_pada' => $status === 'terbit' ? '2025-10-01T09:00:00+07:00' : null, + 'diterbitkan_oleh' => null, + ], + 'isian' => $isian, + ], + ]; +} + +/** + * Item isian — bentuk nyata di dalam `attributes.isian[]`. + * + * Penanda memakai NAMA RESMI API (K4). `pemetaan` adalah ARRAY (Q9/§6a.3). + * + * @param array $ubah + * @return array + */ +function fxIsian(string $kode, array $ubah = []): array +{ + return array_merge([ + 'id' => null, + 'kode' => $kode, + 'label' => ucfirst(str_replace('_', ' ', $kode)), + 'tipe' => 'teks', + 'wajib' => false, + 'satuan' => null, + 'opsi' => null, + 'aturan_validasi' => null, + 'deskripsi' => null, + 'urutan' => 1, + 'diarsipkan' => false, + 'tampil_popup' => false, + 'tampil_filter' => false, + 'tampil_indikator' => false, + 'pemetaan' => [], + ], $ubah); +} + +/** + * Suffix HTTP muat halaman dari layout adminlte (MenuListener). + * + * @return array + */ +function fakeConfigEndpoint(): array +{ + return ['data' => []]; +} + +/** + * Palsukan seluruh endpoint yang dipakai halaman editor skema. + * + * @param array> $sektor + * @param array> $riwayat + * @param array|null $skemaAktif + */ +function fakeEndpointEditor(array $sektor, array $riwayat = [], ?array $skemaAktif = null): void +{ + Http::fake([ + '*/api/v1/config/*' => Http::response(fakeConfigEndpoint(), 200), + '*/api/v1/dataspasial/sektor*' => Http::response(fakeKoleksi($sektor), 200), + // Q9: daftar seluruh versi. + '*/api/v1/dataspasial/skema/riwayat*' => Http::response(fakeKoleksi($riwayat), 200), + // K1/K2: versi aktif + isian. + '*/api/v1/dataspasial/skema?*' => Http::response($skemaAktif ?? [], 200), + '*' => Http::response(['data' => []], 200), + ]); +} + +/** + * Buat peran + pengguna uji pada sebuah team. + * + * ⚠️ Didefinisikan **satu kali** di sini (bukan di tiap berkas test) karena + * PHP men deadly-declare fungsi global — menyalinnya ke beberapa test akan + * fatal "Cannot redeclare makeRole()" saat seluruh suite dijalankan. + * + * ⚠️ `setPermissionsTeamId()` bersifat global per request, jadi peran yang + * dibuat TERAKHIR harusLAH pengguna yang sedang login. Pemanggil yang membuat + * peran lain untuk kasus negatif harusUCAH pemanggil ini setelahnya. + * + * @param array $permissions + */ +function makeRole(string $teamName, array $permissions): User +{ + $team = Team::firstOrCreate(['name' => $teamName], ['menu' => []]); + + setPermissionsTeamId($team->id); + + $role = Role::firstOrCreate([ + 'name' => $teamName, + 'team_id' => $team->id, + 'guard_name' => 'web', + ]); + + $role->syncPermissions(array_map( + fn (string $nama) => Permission::findOrCreate($nama, 'web'), + $permissions + )); + + $user = User::factory()->create(); + $user->forceFill(['name' => 'Uji '.$teamName])->save(); + $user->teams()->attach($team->id, ['id_user' => $user->id, 'id_team' => $team->id]); + $user->unsetRelation('team')->load('team'); + $user->assignRole($role); + + return $user; +} diff --git a/tests/Feature/DataSpasial/SkemaEditorDrafTest.php b/tests/Feature/DataSpasial/SkemaEditorDrafTest.php new file mode 100644 index 00000000..d91c6832 --- /dev/null +++ b/tests/Feature/DataSpasial/SkemaEditorDrafTest.php @@ -0,0 +1,147 @@ + 'https://api.example.test', + 'dataspasial.cache_ttl' => 300, + ]); + + Cache::flush(); + + Setting::updateOrCreate( + ['key' => 'database_gabungan_api_key'], + ['name' => 'API Key', 'value' => 'token-uji'] + ); + + $this->adminSkema = makeRole('admin skema us3', [ + 'dataspasial-skema-read', + 'dataspasial-skema-write', + ]); + + test()->actingAs($this->adminSkema, 'web'); + + $this->urlDraf = route('dataspasial.skema.draf.store'); +}); + +/** + * @param array> $isian + * @return array + */ +function drafUs3(array $isian): array +{ + return ['sektor' => 'pendidikan', 'sektor_nama' => 'Pendidikan', 'isian' => $isian]; +} + +/** + * @return array + */ +function isianUs3(): array +{ + return [ + 'kode' => 'jumlah_siswa', + 'label' => 'Jumlah Siswa', + 'tipe' => 'angka', + 'wajib' => true, + 'satuan' => 'orang', + 'urutan' => 1, + 'popupPeta' => true, + 'sebagaiFilter' => false, + 'sebagaiIndikator' => true, + ]; +} + +it('menyimpan draf lewat AJAX dan membalas JSON tanpa muat ulang halaman', function () { + Http::fake([ + '*/api/v1/dataspasial/skema' => Http::response([ + 'success' => true, + 'data' => ['type' => 'ds_skema_versi', 'id' => '9'], + ], 201), + '*' => Http::response(['data' => []], 200), + ]); + + $this->postJson($this->urlDraf, drafUs3([isianUs3()])) + ->assertOk() + ->assertJsonPath('success', true) + ->assertJsonPath('sektor', 'pendidikan'); + + // ⚠️ Q23 — nomor versi tidak lagi dikirim/diisi oleh OpenKab (Q18). + Http::assertSent(function ($request) { + $body = json_decode((string) $request->body(), true); + + return str_ends_with($request->url(), '/api/v1/dataspasial/skema') + && is_array($body) + && array_key_exists('sektor', $body) + && array_key_exists('sektor_nama', $body) + && array_key_exists('keterangan', $body) + && ! array_key_exists('nomor_versi', $body) + && ! array_key_exists('catatan_perubahan', $body); + }); +}); + +it('mempertahankan isi editor dan membalas pesan generik saat sumber mati', function () { + Http::fake([ + '*/api/v1/dataspasial/skema' => Http::response( + ['message' => 'Modul DataSpasial belum terpasang'], + 503 + ), + '*' => Http::response(['data' => []], 200), + ]); + + // FR-041b — status 503 + pesan generik; isi editor (baris isian) TIDAK hilang + // karena frontend tidak me-reload halaman. + $this->postJson($this->urlDraf, drafUs3([isianUs3()])) + ->assertStatus(503) + ->assertJsonPath('success', false) + ->assertJsonFragment(['message' => 'Data sementara tidak tersedia. Silakan coba lagi nanti.']); +}); + +it('menampilkan pesan yang dapat ditindaklanjuti tanpa membocorkan detail teknis', function () { + Http::fake([ + '*/api/v1/dataspasial/skema' => Http::response([ + 'success' => false, + 'message' => 'Validasi gagal.', + 'errors' => ['sektor_nama' => ['nama sektor sudah dipakai sektor lain.']], + ], 422), + '*' => Http::response(['data' => []], 200), + ]); + + $response = $this->postJson($this->urlDraf, drafUs3([isianUs3()])); + + // FR-021/FR-005 — pesan yang dapat ditindaklanjuti, BUKAN pesan teknis API + // mentah-mentah (detail teknis hanya masuk log, FR-038). + $response->assertStatus(503)->assertJsonPath('success', false); + + $isi = $response->getContent(); + expect($isi) + ->toContain('Data sementara tidak tersedia') + ->not->toContain('nama sektor sudah dipakai sektor lain'); +}); + +it('menolak draf tanpa isian sebelum permintaan dikirim', function () { + // Q23/FR-019/SC-020 — nol isian ditolak di layar. + Http::fake(); + + $this->postJson($this->urlDraf, drafUs3([])) + ->assertStatus(422) + ->assertJsonValidationErrors('isian'); + + Http::assertNothingSent(); +}); diff --git a/tests/Feature/DataSpasial/SkemaEditorIsianTest.php b/tests/Feature/DataSpasial/SkemaEditorIsianTest.php new file mode 100644 index 00000000..523ccb81 --- /dev/null +++ b/tests/Feature/DataSpasial/SkemaEditorIsianTest.php @@ -0,0 +1,438 @@ + 'https://api.example.test', + 'dataspasial.cache_ttl' => 300, + ]); + + Cache::flush(); + + Setting::updateOrCreate( + ['key' => 'database_gabungan_api_key'], + ['name' => 'API Key', 'value' => 'token-uji'] + ); + + $this->user = makeRole('admin skema us2', [ + 'dataspasial-skema-read', + 'dataspasial-skema-write', + 'dataspasial-skema-edit', + 'dataspasial-skema-delete', + ]); + + test()->actingAs($this->user, 'web'); + + $this->urlDraf = route('dataspasial.skema.draf.store'); +}); + +/** + * @param array $ubah + * @return array + */ +function isian(string $kode, array $ubah = []): array +{ + return array_merge([ + 'id' => null, + 'kode' => $kode, + 'label' => ucfirst(str_replace('_', ' ', $kode)), + 'tipe' => 'teks', + 'wajib' => false, + 'satuan' => null, + 'opsi' => null, + 'urutan' => 1, + 'diarsipkan' => false, + 'popupPeta' => false, + 'sebagaiFilter' => false, + 'sebagaiIndikator' => false, + ], $ubah); +} + +/** + * @param array> $isian + * @return array + */ +function isiPayload(array $isian): array +{ + return ['sektor' => 'pendidikan', 'sektor_nama' => 'Pendidikan', 'isian' => $isian]; +} + +// --------------------------------------------------------------------------- +// FR-017/Q14 — `kode` diturunkan dari `label`, bukan diketik admin +// --------------------------------------------------------------------------- + +it('tidak merender kolom kode sama sekali di tabel isian', function () { + // ⚠️ FR-017/Q14 — `kode` diturunkan otomatis dari `label`, jadi kolomnya + // DIHAPUS: tidak ada header "Kode" dan tidak ada input `input-kode`. + $tabel = file_get_contents(resource_path('views/dataspasial/skema/partials/tabel-isian.blade.php')); + $baris = file_get_contents(resource_path('views/dataspasial/skema/partials/form-isian.blade.php')); + + expect($tabel)->not->toContain('Kode') + ->and($tabel)->not->toContain('input-kode') + ->and($baris)->not->toContain('input-kode'); + + // Kolom `label` tetap ada dan tetap dapat disunting. + expect($baris)->toContain('input-label'); +}); + +it('tidak memaccess properti dataset bergaris dalam serialisasi isian', function () { + $js = file_get_contents(public_path('js/dataspasial/schema-editor.js')); + + // ⚠️ REGRESSION: `dataset?.aturan-hitung` diparse sebagai pengurangan + // (`aturan - hitung`) → ReferenceError setiap kali `bacaDariDom()` jalan, + // yaitu setiap kali admin menekan "Simpan draf". Atribut dataset memakai + // camelCase, jadi aksesnya harus `dataset.aturanHitung`. + // Komentar dibuang dulu supaya penjelasan di dalam kode tidak ikut + // dianggap sebagai akses properti yang salah. + $kode = preg_replace(['#//[^\n]*#', '#/\*.*?\*/#s'], '', $js); + + expect($kode)->not->toMatch('/dataset\??\.[a-zA-Z_]*-/') + ->and($js)->toContain('dataset?.tabelSumber ?? null') + ->and($js)->toContain('dataset?.kolomSumber ?? null'); +}); + +it('tidak mengirim kolom kode dari DOM saat menyerialisasi isian', function () { + $js = file_get_contents(public_path('js/dataspasial/schema-editor.js')); + + // ⚠️ `kode` harus SELALU dihitung ulang dari `label` saat serialisasi — + // tidak boleh dibaca dari input yang bisa diedit. + expect($js)->toContain('kode: slugKodeDariLabel(') + ->and($js)->not->toContain(".input-kode')?.value?.trim()"); +}); + +it('membangkitkan kode dari label memakai pola snake_case yang sama seperti kode sektor', function () { + $editor = app(SkemaEditorService::class); + + expect($editor->petakanIsian(['id' => '31', 'attributes' => isian('jumlah_siswa')])['kode']) + ->toBe('jumlah_siswa'); +}); + +it('menampilkan isian lengkap dengan seluruh atributnya', function () { + $isi = isian('jumlah_siswa', [ + 'tipe' => 'angka', + 'wajib' => true, + 'satuan' => 'orang', + 'urutan' => 1, + ]); + + $editor = app(SkemaEditorService::class); + + expect($editor->petakanIsian(['id' => '31', 'attributes' => $isi]))->toBe([ + 'id' => 31, + 'kode' => 'jumlah_siswa', + 'label' => 'Jumlah siswa', + 'tipe' => 'angka', + 'wajib' => true, + 'satuan' => 'orang', + 'opsi' => [], + 'urutan' => 1, + 'diarsipkan' => false, + 'sumber' => 'manual', + 'rujukan' => null, + // ⚠️ Diturunkan dari `rujukan_sumber`; kosong bila pemetaan belum punya + // rujukan tabel.kolom (§6a.4). + 'tabelSumber' => '', + 'kolomSumber' => '', + 'aturanHitung' => null, + 'popupPeta' => false, + 'sebagaiFilter' => false, + 'sebagaiIndikator' => false, + ]); +}); + +it('mengurutkan isian berdasarkan urutan yang tersimpan', function () { + $editor = app(SkemaEditorService::class); + + $hasil = $editor->petakanDaftarIsian([ + ['id' => '2', 'attributes' => isian('b', ['urutan' => 2])], + ['id' => '1', 'attributes' => isian('a', ['urutan' => 1])], + ['id' => '3', 'attributes' => isian('c', ['urutan' => 3])], + ]); + + expect(array_column($hasil, 'kode'))->toBe(['a', 'b', 'c']) + ->and(array_column($hasil, 'urutan'))->toBe([1, 2, 3]); +}); + +// --------------------------------------------------------------------------- +// FR-010 — kolom opsi & satuan hanya berlaku pada tipenya +// --------------------------------------------------------------------------- + +it('mengaktifkan kolom opsi hanya untuk tipe pilihan', function () { + // ⚠️ `TipeIsian::Pilihan` adalah konstanta PHP biasa (string), sehingga + // instance enum harus dibangun lewat `fromValue()`. + $memakaiPilihan = fn (string $tipe): bool => TipeIsian::fromValue($tipe)->memakaiPilihan(); + $memakaiSatuan = fn (string $tipe): bool => TipeIsian::fromValue($tipe)->memakaiSatuan(); + + expect($memakaiPilihan('pilihan'))->toBeTrue() + ->and($memakaiPilihan('teks'))->toBeFalse() + ->and($memakaiPilihan('angka'))->toBeFalse() + ->and($memakaiPilihan('ya_tidak'))->toBeFalse() + ->and($memakaiPilihan('tanggal'))->toBeFalse(); + + // Satuan hanya relevan pada tipe `angka`. + expect($memakaiSatuan('angka'))->toBeTrue() + ->and($memakaiSatuan('teks'))->toBeFalse() + ->and($memakaiSatuan('tanggal'))->toBeFalse(); +}); + +it('menerima butir pilihan pada tipe pilihan', function () { + Http::fake(['*' => Http::response([ + 'data' => ['type' => 'ds_skema_versi', 'id' => '9', 'attributes' => ['nomor_versi' => 1]], + ], 201)]); + + $this->post($this->urlDraf, isiPayload([ + isian('jenis_sekolah', ['tipe' => 'pilihan', 'opsi' => ['Negeri', 'Swasta']]), + ]))->assertSessionHasNoErrors(); + + Http::assertSent(fn ($request) => str_contains($request->body(), 'Negeri') + && str_contains($request->body(), 'Swasta')); +}); + +it('mengabaikan satuan pada tipe yang tidak memerlukan satuan', function () { + Http::fake(['*' => Http::response([ + 'data' => ['type' => 'ds_skema_versi', 'id' => '9', 'attributes' => ['nomor_versi' => 1]], + ], 201)]); + + // Satuan pada tipe `teks` TIDAK boleh menjadi alasan penyimpanan gagal. + $this->post($this->urlDraf, isiPayload([ + isian('catatan', ['tipe' => 'teks', 'satuan' => 'orang']), + ]))->assertSessionHasNoErrors(); +}); + +it('mengabaikan kolom opsi pada tipe selain pilihan saat menyusun draf', function () { + $editor = app(SkemaEditorService::class); + + $payload = $editor->composeDrafPayload([ + [ + 'kode' => 'catatan', + 'label' => 'Catatan', + 'tipe' => 'teks', + 'wajib' => false, + 'satuan' => null, + 'opsi' => ['tidak-boleh-dipakai'], + ], + ], ['sektor' => 'pendidikan']); + + expect($payload['isian'][0]['opsi'])->toBeNull(); +}); + +// --------------------------------------------------------------------------- +// FR-012 — urutan +// --------------------------------------------------------------------------- + +it('menulis ulang urutan menjadi 1..N tanpa celah saat menyusun draf', function () { + Http::fake(['*' => Http::response([ + 'data' => ['type' => 'ds_skema_versi', 'id' => '9', 'attributes' => ['nomor_versi' => 1]], + ], 201)]); + + // Urutan sengaja dikirim acak (hasil urutan baru setelah seret). + $this->post($this->urlDraf, isiPayload([ + isian('a', ['urutan' => 1]), + isian('b', ['urutan' => 2]), + isian('c', ['urutan' => 3]), + ]))->assertSessionHasNoErrors(); + + Http::assertSent(function ($request) { + $body = json_decode($request->body(), true); + + return array_column($body['isian'] ?? [], 'urutan') === [1, 2, 3] + && array_column($body['isian'] ?? [], 'kode') === ['a', 'b', 'c']; + }); +}); + +// --------------------------------------------------------------------------- +// FR-015 — isian terpetakan tampil terkunci +// --------------------------------------------------------------------------- + +it('menandai isian sebagai terpetakan dari pemetaan yang tertanam', function () { + $editor = app(SkemaEditorService::class); + + // ⚠️ VERIFIKASI LANGSUNG: pemetaan TERTANAM di dalam tiap isian + // (`attributes.pemetaan[]`), bukan diambil dari endpoint terpisah. + $hasil = $editor->petakanDaftarIsian([ + ['id' => '31', 'attributes' => isian('jumlah_siswa', ['id' => 31, 'pemetaan' => [ + ['id' => 3951, 'sumber' => 'dataPresisi', 'rujukan_sumber' => 'data_presisi_pendidikan.*', 'aturan_hitung' => null], + ]])], + ['id' => '32', 'attributes' => isian('luas_ha', ['id' => 32, 'urutan' => 2, 'pemetaan' => []])], + ]); + + // `sumber` berisi ENUM API, bukan kode sektor (kontrak §6a.5). + expect($hasil[0]['sumber'])->toBe('dataPresisi') + ->and($hasil[0]['rujukan'])->toBe('data_presisi_pendidikan.*') + // Isian tanpa pemetaan tetap manual (FR-023). + ->and($hasil[1]['sumber'])->toBe('manual') + ->and($hasil[1]['rujukan'])->toBeNull(); +}); + +it('mengabaikan pemetaan yang tidak memiliki sektor sumber', function () { + $editor = app(SkemaEditorService::class); + + $isian = $editor->petakanDaftarIsian([['id' => '31', 'attributes' => isian('jumlah_siswa')]]); + + $hasil = $editor->terapkanPemetaan($isian, [ + ['id' => 5, 'sumber' => null, 'rujukan' => 'x', 'aturan_hitung' => null], + ['id' => 6, 'sumber' => '', 'rujukan' => 'y', 'aturan_hitung' => null], + ]); + + expect($hasil[0]['sumber'])->toBe('manual'); +}); + +it('menyertakan pemetaan pada badan draf hanya bila ada', function () { + $editor = app(SkemaEditorService::class); + + $payload = $editor->composeDrafPayload([ + ['kode' => 'a', 'label' => 'A', 'tipe' => 'teks', 'urutan' => 1, 'sumber' => 'manual', 'tabelSumber' => null, 'kolomSumber' => null, 'rujukan' => null], + ['kode' => 'b', 'label' => 'B', 'tipe' => 'teks', 'urutan' => 2, + 'sumber' => 'openSID', 'tabelSumber' => 'tabel', 'kolomSumber' => 'kolom', 'rujukan' => null], + ['kode' => 'c', 'label' => 'C', 'tipe' => 'teks', 'urutan' => 3, + 'sumber' => 'openSID', 'rujukan' => 'tbl.kolom'], + ], ['sektor' => 'pendidikan']); + + expect($payload['isian'][0]['pemetaan'])->toBeNull() + // ⚠️ Tabel & kolom disambung menjadi SATU `rujukan_sumber` (§6a.4) — + // entitas `ds_pemetaan` tidak punya `tabel_sumber`/`kolom_sumber`. + ->and($payload['isian'][1]['pemetaan'])->toBe([[ + 'sumber' => 'openSID', + 'rujukan_sumber' => 'tabel.kolom', + ]]) + // `rujukan` yang sudah jadi dipakai apa adanya tanpa disambung ulang. + ->and($payload['isian'][2]['pemetaan'])->toBe([[ + 'sumber' => 'openSID', + 'rujukan_sumber' => 'tbl.kolom', + ]]); +}); + +// --------------------------------------------------------------------------- +// FR-020 — validasi tetap satu-satunya penentu +// --------------------------------------------------------------------------- + +it('menolak kode bentrok walau validasi klien lolos', function () { + Http::fake(); + + $this->post($this->urlDraf, isiPayload([ + isian('jumlah_siswa', ['urutan' => 1]), + isian('jumlah_siswa', ['urutan' => 2]), + ]))->assertSessionHasErrors('isian.1.kode'); + + Http::assertNothingSent(); +}); + +it('menolak kode tidak valid walau klien mengirimnya', function () { + Http::fake(); + + $this->post($this->urlDraf, isiPayload([isian('Jumlah Siswa')])) + ->assertSessionHasErrors('isian.0.kode'); + + Http::assertNothingSent(); +}); + +// --------------------------------------------------------------------------- +// Editor benar-benar menampilkan baris isian (FR-009) +// --------------------------------------------------------------------------- + +it('merender baris isian pada halaman editor dengan tombol tambah isian', function () { + Http::fake([ + '*/api/v1/config/*' => Http::response(['data' => []], 200), + '*/api/v1/dataspasial/sektor*' => Http::response([ + 'data' => [[ + 'type' => 'ds_sektor', 'id' => '1', + 'attributes' => ['kode' => 'pendidikan', 'nama' => 'Pendidikan', 'ikon' => null, 'status' => 'aktif'], + ]], + 'meta' => ['pagination' => ['total' => 1, 'count' => 1, 'per_page' => 100, 'current_page' => 1, 'total_pages' => 1]], + ], 200), + // ⚠️ Q9: daftar skema dari `/skema/riwayat` (bukan `/skema?`). + '*/api/v1/dataspasial/skema/riwayat*' => Http::response([ + 'data' => [[ + 'type' => 'riwayat versi skema', 'id' => '9', + 'attributes' => ['sektor_id' => 1, 'nomor_versi' => 3, 'status' => 'draf', + 'catatan_perubahan' => null, 'diterbitkan_pada' => null, 'diterbitkan_oleh' => null], + ]], + ], 200), + // ⚠️ K2/K3: versi aktif — `versi_skema` bersarang, isian ikut di dalamnya. + '*/api/v1/dataspasial/skema?*' => Http::response([ + 'type' => 'skema data spasial', 'id' => '9', + 'attributes' => [ + 'sektor' => ['id' => 1, 'kode' => 'pendidikan', 'nama' => 'Pendidikan'], + 'versi_skema' => ['id' => 9, 'sektor_id' => 1, 'nomor_versi' => 3, 'status' => 'draf', + 'catatan_perubahan' => null, 'diterbitkan_pada' => null, 'diterbitkan_oleh' => null], + 'isian' => [ + isian('jumlah_siswa', ['id' => 31, 'tipe' => 'angka', 'wajib' => true, 'satuan' => 'orang', 'urutan' => 1]), + isian('jenis_sekolah', ['id' => 32, 'tipe' => 'pilihan', 'opsi' => ['Negeri', 'Swasta'], 'urutan' => 2]), + ], + ], + ], 200), + '*' => Http::response(['data' => []], 200), + ]); + + $content = $this->get(route('dataspasial.skema', ['sektor' => 'pendidikan', 'skema' => 9])) + ->assertOk() + ->getContent(); + + expect($content) + ->toContain('jumlah_siswa') + ->toContain('Jumlah siswa') + ->toContain('jenis_sekolah') + ->toContain('Negeri') + ->toContain('btn-tambah-isian') + // ⚠️ Kolom "Pilihan" tidak lagi ditampilkan pada tabel editor, tetapi + // nilai opsi yang tersimpan di API tetap dibawa lewat `data-opsi` + // sehingga tidak hilang saat Simpan draf. + ->not->toContain('input-opsi') + ->toContain('data-opsi=') + // Urutan tampil pada kolom # (FR-012). + ->toContain('class="text-center align-middle urutan text-muted"'); +}); + +it('menampilkan keadaan kosong dengan ajakan menambah isian pertama', function () { + Http::fake([ + '*/api/v1/config/*' => Http::response(['data' => []], 200), + '*/api/v1/dataspasial/sektor*' => Http::response([ + 'data' => [[ + 'type' => 'ds_sektor', 'id' => '1', + 'attributes' => ['kode' => 'pendidikan', 'nama' => 'Pendidikan', 'ikon' => null, 'status' => 'aktif'], + ]], + 'meta' => ['pagination' => ['total' => 1, 'count' => 1, 'per_page' => 100, 'current_page' => 1, 'total_pages' => 1]], + ], 200), + '*/api/v1/dataspasial/skema?*' => Http::response([ + 'type' => 'skema data spasial', 'id' => '9', + 'attributes' => [ + 'sektor' => ['id' => 1, 'kode' => 'pendidikan', 'nama' => 'Pendidikan'], + 'versi_skema' => ['id' => 9, 'sektor_id' => 1, 'nomor_versi' => 1, + 'status' => 'draf', 'catatan_perubahan' => null, + 'diterbitkan_pada' => null, 'diterbitkan_oleh' => null], + 'isian' => [], + ], + ], 200), + '*' => Http::response(['data' => []], 200), + ]); + + $this->get(route('dataspasial.skema', ['sektor' => 'pendidikan', 'skema' => 9])) + ->assertOk() + ->assertSee('Belum ada isian pada skema ini') + ->assertSee('Tambah Isian Baru'); +}); diff --git a/tests/Feature/DataSpasial/SkemaEditorPemetaanTest.php b/tests/Feature/DataSpasial/SkemaEditorPemetaanTest.php new file mode 100644 index 00000000..81189cc8 --- /dev/null +++ b/tests/Feature/DataSpasial/SkemaEditorPemetaanTest.php @@ -0,0 +1,228 @@ + 'https://api.example.test', + 'dataspasial.cache_ttl' => 300, + ]); + + Cache::flush(); + + Setting::updateOrCreate( + ['key' => 'database_gabungan_api_key'], + ['name' => 'API Key', 'value' => 'token-uji'] + ); + + $team = Team::firstOrCreate(['name' => 'admin skema us4'], ['menu' => []]); + setPermissionsTeamId($team->id); + + $role = Role::firstOrCreate(['name' => 'admin skema us4', 'team_id' => $team->id, 'guard_name' => 'web']); + $role->syncPermissions(array_map( + fn (string $n) => Permission::findOrCreate($n, 'web'), + ['dataspasial-skema-read', 'dataspasial-skema-write', 'dataspasial-skema-edit', 'dataspasial-skema-delete'] + )); + + $this->user = User::factory()->create(); + $this->user->forceFill(['name' => 'Uji Admin Skema US4'])->save(); + $this->user->teams()->attach($team->id, ['id_user' => $this->user->id, 'id_team' => $team->id]); + $this->user->unsetRelation('team')->load('team'); + $this->user->assignRole($role); + + test()->actingAs($this->user, 'web'); + + $this->urlDraf = route('dataspasial.skema.draf.store'); +}); + +/** + * @param array $ubah + * @return array + */ +function isianPemetaan(array $ubah = []): array +{ + return array_merge([ + 'id' => null, + 'kode' => 'jumlah_siswa', + 'label' => 'Jumlah Siswa', + 'tipe' => 'angka', + 'wajib' => true, + 'satuan' => 'orang', + 'opsi' => null, + 'urutan' => 1, + 'popupPeta' => false, + 'sebagaiFilter' => false, + 'sebagaiIndikator' => false, + 'tabelSumber' => null, 'kolomSumber' => null, + ], $ubah); +} + +/** + * @param array> $isian + * @return array + */ +function drafPemetaan(array $isian): array +{ + return ['sektor' => 'pendidikan', 'sektor_nama' => 'Pendidikan', 'isian' => $isian]; +} + +// --------------------------------------------------------------------------- +// US4#2 — memilih sektor sumber menandai isian terpetakan (FR-023/FR-024) +// --------------------------------------------------------------------------- + +it('menandai isian sebagai terpetakan dan menampilkan nama sumbernya', function () { + fakeEndpointEditor( + [fxSektor('1', 'pendidikan', 'Pendidikan')], + [fxRiwayatVersi('9', 1, 1, 'draf')], + fxSkemaAktif('9', 1, 'pendidikan', 'Pendidikan', 1, 'draf', [ + fxIsian('luas_ha', ['id' => 31, 'pemetaan' => [ + ['id' => 5, 'sumber' => 'openSID', 'rujukan_sumber' => 'luas_wilayah.*', 'aturan_hitung' => null], + ]]), + ]) + ); + + $this->get(route('dataspasial.skema', ['sektor' => 'pendidikan', 'skema' => 9])) + ->assertOk() + ->assertSee('openSID') + ->assertSee('badge-terpetakan'); +}); + +it('menyertakan pemetaan pada badan draf saat menyimpan', function () { + Http::fake(['*' => Http::response([ + 'data' => ['type' => 'skema data spasial', 'id' => '9', 'attributes' => [ + 'versi_skema' => ['id' => 9, 'nomor_versi' => 1, 'status' => 'draf'], 'isian' => []]], + ], 201)]); + + $this->post($this->urlDraf, drafPemetaan([ + isianPemetaan(['sumber' => 'openSID', 'tabelSumber' => 'luas_wilayah', 'kolomSumber' => '*']), + ]))->assertSessionHasNoErrors(); + + Http::assertSent(function ($request) { + $body = json_decode($request->body(), true); + $pemetaan = $body['isian'][0]['pemetaan'] ?? []; + + // Pemetaan dikirim sebagai ARRAY di dalam isian (bentuk nyata §6a.3). + // ⚠️ Tabel & kolom disambung menjadi SATU `rujukan_sumber` (§6a.4). + return is_array($pemetaan) + && ($pemetaan[0]['sumber'] ?? null) === 'openSID' + && ($pemetaan[0]['rujukan_sumber'] ?? null) === 'luas_wilayah.*' + && ! array_key_exists('tabel_sumber', $pemetaan[0]) + && ! array_key_exists('kolom_sumber', $pemetaan[0]); + }); +}); + +it('mengirim pemetaan null ketika isian diisi manual', function () { + Http::fake(['*' => Http::response([ + 'data' => ['type' => 'ds_skema_versi', 'id' => '9', 'attributes' => ['nomor_versi' => 1]], + ], 201)]); + + $this->post($this->urlDraf, drafPemetaan([isianPemetaan(['sektorSumber' => null, 'rujukan' => null])])) + ->assertSessionHasNoErrors(); + + Http::assertSent(function ($request) { + $body = json_decode($request->body(), true); + + // `??` akan menganggap `null` sebagai "absent", jadi harus memakai + // `array_key_exists()` untuk memastikan kuncinya benar-benar dikirim + // dengan nilai null (bukan sekadar dihilangkan). + return array_key_exists('pemetaan', $body['isian'][0]) + && $body['isian'][0]['pemetaan'] === null; + }); +}); + +// --------------------------------------------------------------------------- +// US4#3 — menghapus pemetaan mengembalikan isian ke manual (FR-023) +// --------------------------------------------------------------------------- + +it('mengembalikan isian ke manual ketika pemetaan dihapus', function () { + $editor = app(SkemaEditorService::class); + + $denganPemetaan = $editor->petakanDaftarIsian([ + ['id' => '31', 'attributes' => fxIsian('luas_ha', ['id' => 31, 'pemetaan' => [ + ['id' => 5, 'sumber' => 'openSID', 'rujukan_sumber' => 'luas', 'aturan_hitung' => null], + ]])], + ]); + + expect($denganPemetaan[0]['sumber'])->toBe('openSID'); + + // Lalu dilepas (API mengembalikan isian tanpa pemetaan). + $manual = $editor->petakanDaftarIsian([ + ['id' => '31', 'attributes' => fxIsian('luas_ha', ['id' => 31, 'pemetaan' => []])], + ]); + + expect($manual[0]['sumber'])->toBe('manual') + ->and($manual[0]['rujukan'])->toBeNull(); +}); + +it('menampilkan label Manual untuk isian tanpa pemetaan', function () { + fakeEndpointEditor( + [fxSektor('1', 'pendidikan', 'Pendidikan')], + [fxRiwayatVersi('9', 1, 1, 'draf')], + fxSkemaAktif('9', 1, 'pendidikan', 'Pendidikan', 1, 'draf', [ + fxIsian('jumlah_siswa', ['id' => 31]), + ]) + ); + + $this->get(route('dataspasial.skema', ['sektor' => 'pendidikan', 'skema' => 9])) + ->assertOk() + ->assertSee('badge-manual'); +}); + +// --------------------------------------------------------------------------- +// US4#4 — sumber bermasalah tidak merusak pemetaan lama (FR-005) +// --------------------------------------------------------------------------- + +it('tidak merusak pemetaan yang sudah tersimpan saat sumber pemetaan bermasalah', function () { + $editor = app(SkemaEditorService::class); + + $isian = $editor->petakanDaftarIsian([['id' => '31', 'attributes' => isianPemetaan(['id' => 31])]]); + + // Endpoint pemetaan unavailable → controller TIDAK menerapkan apa pun, + // sehingga status tetap default `manual` dan tidak ada data rusak. + $hasil = $editor->terapkanPemetaan($isian, []); + + expect($hasil[0]['sumber'])->toBe('manual') + ->and($isian[0]['kode'])->toBe('jumlah_siswa') + ->and($isian[0]['label'])->toBe('Jumlah Siswa'); +}); + +it('tidak membocorkan pesan teknis pemetaan ke pengguna saat endpoint gagal', function () { + Http::fake([ + '*/api/v1/config/*' => Http::response(['data' => []], 200), + '*/api/v1/dataspasial/sektor*' => Http::response([ + 'data' => [['type' => 'ds_sektor', 'id' => '1', + 'attributes' => ['kode' => 'pendidikan', 'nama' => 'Pendidikan', 'ikon' => null, 'status' => 'aktif']]], + 'meta' => ['pagination' => ['total' => 1, 'count' => 1, 'per_page' => 200, 'current_page' => 1, 'total_pages' => 1]], + ], 200), + ]); + + $content = $this->get(route('dataspasial.skema', ['sektor' => 'pendidikan', 'skema' => 9])) + ->assertOk() + ->getContent(); + + expect($content)->not->toContain('Modul pemetaan belum terpasang'); +}); diff --git a/tests/Feature/DataSpasial/SkemaEditorPratinjauTest.php b/tests/Feature/DataSpasial/SkemaEditorPratinjauTest.php new file mode 100644 index 00000000..6bb6f089 --- /dev/null +++ b/tests/Feature/DataSpasial/SkemaEditorPratinjauTest.php @@ -0,0 +1,334 @@ + 'https://api.example.test', + 'dataspasial.cache_ttl' => 300, + ]); + + Cache::flush(); + + Setting::updateOrCreate( + ['key' => 'database_gabungan_api_key'], + ['name' => 'API Key', 'value' => 'token-uji'] + ); + + $team = Team::firstOrCreate(['name' => 'admin skema us6'], ['menu' => []]); + setPermissionsTeamId($team->id); + + $role = Role::firstOrCreate(['name' => 'admin skema us6', 'team_id' => $team->id, 'guard_name' => 'web']); + $role->syncPermissions(array_map( + fn (string $n) => Permission::findOrCreate($n, 'web'), + ['dataspasial-skema-read', 'dataspasial-skema-write', 'dataspasial-skema-edit', 'dataspasial-skema-delete'] + )); + + $this->user = User::factory()->create(); + $this->user->forceFill(['name' => 'Uji Admin Skema US6'])->save(); + $this->user->teams()->attach($team->id, ['id_user' => $this->user->id, 'id_team' => $team->id]); + $this->user->unsetRelation('team')->load('team'); + $this->user->assignRole($role); + + test()->actingAs($this->user, 'web'); +}); + +/** + * @param array $ubah + * @return array + */ +function isianPratinjau(array $ubah = []): array +{ + return array_merge([ + 'id' => null, + 'kode' => 'jumlah_siswa', + 'label' => 'Jumlah Siswa', + 'tipe' => 'angka', + 'wajib' => true, + 'satuan' => 'orang', + 'opsi' => null, + 'urutan' => 1, + 'popupPeta' => false, + 'sebagaiFilter' => false, + 'sebagaiIndikator' => false, + 'sektorSumber' => null, 'rujukan' => null, + ], $ubah); +} + +/** + * Fixture isian yang disalin dari `contracts/skema-editor-api.md` §2.2. + * + * Test parity (T068) memakai SUSUNAN YANG SAMA dengan contoh kontrak agar + * perubahan pada read model langsung terlihat di diff. + * + * @return array> + */ +function fixtureIsianKontrak(): array +{ + return [ + isianPratinjau(['id' => 31, 'kode' => 'jumlah_siswa', 'label' => 'Jumlah Siswa', + 'tipe' => 'angka', 'wajib' => true, 'satuan' => 'orang', 'urutan' => 1, + 'popupPeta' => true, 'sebagaiIndikator' => true]), + isianPratinjau(['id' => 32, 'kode' => 'jenis_sekolah', 'label' => 'Jenis Sekolah', + 'tipe' => 'pilihan', 'wajib' => false, 'satuan' => null, 'urutan' => 2, + 'opsi' => ['Negeri', 'Swasta'], 'sebagaiFilter' => true]), + isianPratinjau(['id' => 33, 'kode' => 'luas_ha', 'label' => 'Luas (ha)', + 'tipe' => 'angka', 'wajib' => false, 'satuan' => 'ha', 'urutan' => 3]), + isianPratinjau(['id' => 34, 'kode' => 'tanggal_pendataan', 'label' => 'Tanggal Pendataan', + 'tipe' => 'tanggal', 'wajib' => false, 'satuan' => null, 'urutan' => 4]), + isianPratinjau(['id' => 35, 'kode' => 'aktif', 'label' => 'Aktif', + 'tipe' => 'ya_tidak', 'wajib' => false, 'satuan' => null, 'urutan' => 5]), + ]; +} + +// --------------------------------------------------------------------------- +// FR-034 — pratinjau mencerminkan susunan isian +// --------------------------------------------------------------------------- + +it('mempertahankan urutan isian pada pratinjau sesuai urutan tersimpan', function () { + $editor = app(SkemaEditorService::class); + + // Sengaja terbalik agar urutan TIDAK ikut urutan respons API. + $acak = array_reverse(fixtureIsianKontrak()); + + $hasil = $editor->petakanDaftarIsian(array_map( + fn (array $b): array => ['id' => (string) $b['id'], 'attributes' => $b], + $acak + )); + + expect(array_column($hasil, 'kode'))->toBe([ + 'jumlah_siswa', 'jenis_sekolah', 'luas_ha', 'tanggal_pendataan', 'aktif', + ])->and(array_column($hasil, 'urutan'))->toBe([1, 2, 3, 4, 5]); +}); + +it('menyatakan isian wajib dan satuan pada pratinjau', function () { + $editor = app(SkemaEditorService::class); + + $hasil = $editor->petakanDaftarIsian(array_map( + fn (array $b): array => ['id' => (string) $b['id'], 'attributes' => $b], + fixtureIsianKontrak() + )); + + $wajib = array_values(array_filter($hasil, fn (array $b): bool => $b['wajib'])); + + expect($wajib)->toHaveCount(1) + ->and($wajib[0]['kode'])->toBe('jumlah_siswa') + ->and($wajib[0]['satuan'])->toBe('orang'); + + // Satuan hanya relevan pada tipe angka (FR-010). + $luas = array_values(array_filter($hasil, fn (array $b): bool => $b['kode'] === 'luas_ha'))[0]; + + expect(TipeIsian::fromValue($luas['tipe'])->memakaiSatuan())->toBeTrue(); + + $tanggal = array_values(array_filter($hasil, fn (array $b): bool => $b['kode'] === 'tanggal_pendataan'))[0]; + + expect(TipeIsian::fromValue($tanggal['tipe'])->memakaiSatuan())->toBeFalse() + ->and($tanggal['satuan'])->toBeNull(); +}); + +// --------------------------------------------------------------------------- +// FR-015 — isian terpetakan terkunci +// --------------------------------------------------------------------------- + +it('menandai isian terpetakan agar tampil terkunci di pratinjau', function () { + $editor = app(SkemaEditorService::class); + + // ⚠️ VERIFIKASI LANGSUNG: pemetaan TERTANAM di isian; `sumber` = ENUM API. + $hasil = $editor->petakanDaftarIsian([ + ['id' => '31', 'attributes' => fxIsian('jumlah_siswa', ['id' => 31, 'urutan' => 1, 'pemetaan' => [ + ['id' => 3951, 'sumber' => 'dataPresisi', 'rujukan_sumber' => 'x', 'aturan_hitung' => null], + ]])], + ['id' => '32', 'attributes' => fxIsian('luas_ha', ['id' => 32, 'urutan' => 2])], + ]); + + expect($hasil[0]['sumber'])->toBe('dataPresisi') + ->and($hasil[0]['rujukan'])->toBe('x') + // Sisa isian tetap manual → tidak terkunci. + ->and($hasil[1]['sumber'])->toBe('manual'); +}); + +it('menyimpan keterangan sumber yang terbaca pengguna', function () { + $editor = app(SkemaEditorService::class); + + $hasil = $editor->petakanDaftarIsian([ + ['id' => '31', 'attributes' => fxIsian('luas_ha', ['id' => 31, 'pemetaan' => [ + ['id' => 1, 'sumber' => 'openSID', 'rujukan_sumber' => 'luas_wilayah.*', 'aturan_hitung' => null], + ]])], + ]); + + // `sumber` adalah teks yang ditampilkan pada badge & pratinjau terkunci. + expect($hasil[0]['sumber'])->toBeString()->toBe('openSID') + ->and($hasil[0]['rujukan'])->toBe('luas_wilayah.*'); +}); + +it('menandai nilai pemetaan kosong agar pratinjau memberi keterangan belum tersedia (FR-037)', function () { + $editor = app(SkemaEditorService::class); + + // Pemetaan ada tapi rujukannya kosong → nilai belum tersedia dari sumber. + $hasil = $editor->petakanDaftarIsian([ + ['id' => '31', 'attributes' => fxIsian('luas_ha', ['id' => 31, 'pemetaan' => [ + ['id' => 1, 'sumber' => 'openSID', 'rujukan_sumber' => null, 'aturan_hitung' => null], + ]])], + ]); + + expect($hasil[0]['sumber'])->toBe('openSID') + ->and($hasil[0]['rujukan'])->toBeNull(); +}); + +// --------------------------------------------------------------------------- +// T068 — PARITAS DENGAN OPENSID (SC-006) +// Fixture dari `contracts/skema-editor-api.md` §2.2 — sumber kebenaran tunggal. +// --------------------------------------------------------------------------- + +it('mempertahankan paritas atribut formulir dengan OpenSID untuk skema yang sama', function () { + $editor = app(SkemaEditorService::class); + + $hasil = $editor->petakanDaftarIsian(array_map( + fn (array $b): array => ['id' => (string) $b['id'], 'attributes' => $b], + fixtureIsianKontrak() + )); + + // Kontrak listing yang harus dipertahankan persis oleh OpenKab: + // urutan · kode · label · tipe · wajib · satuan · opsi + $hasilFormulir = array_map(fn (array $b): array => [ + $b['urutan'], + $b['kode'], + $b['label'], + $b['tipe'], + $b['wajib'], + $b['satuan'], + $b['opsi'], + ], $hasil); + + expect($hasilFormulir)->toBe([ + [1, 'jumlah_siswa', 'Jumlah Siswa', 'angka', true, 'orang', []], + [2, 'jenis_sekolah', 'Jenis Sekolah', 'pilihan', false, null, ['Negeri', 'Swasta']], + [3, 'luas_ha', 'Luas (ha)', 'angka', false, 'ha', []], + [4, 'tanggal_pendataan', 'Tanggal Pendataan', 'tanggal', false, null, []], + [5, 'aktif', 'Aktif', 'ya_tidak', false, null, []], + ]); +}); + +it('menjaga agar penyimpangan paritas terdeteksi saat data berubah', function () { + $editor = app(SkemaEditorService::class); + + $hasil = $editor->petakanDaftarIsian(array_map( + fn (array $b): array => ['id' => (string) $b['id'], 'attributes' => $b], + fixtureIsianKontrak() + )); + + // Guard: kalau ada isian yang label/satuan-nya berubah diam-diam, test ini + // harus gagal — itulah mekanisme deteksi penyimpangan paritas (SC-006). + $paritas = array_map(fn (array $b): string => $b['label'].'|'.$b['satuan'], $hasil); + + expect($paritas)->toBe([ + 'Jumlah Siswa|orang', + 'Jenis Sekolah|', + 'Luas (ha)|ha', + 'Tanggal Pendataan|', + 'Aktif|', + ]); +}); + +// --------------------------------------------------------------------------- +// T070 — Panel pratinjau dirender pada halaman +// --------------------------------------------------------------------------- + +it('merender panel pratinjau beserta wadah yang diisi JS', function () { + Http::fake([ + '*/api/v1/config/*' => Http::response(['data' => []], 200), + '*/api/v1/dataspasial/sektor*' => Http::response([ + 'data' => [['type' => 'ds_sektor', 'id' => '1', + 'attributes' => ['kode' => 'pendidikan', 'nama' => 'Pendidikan', 'ikon' => null, 'status' => 'aktif']]], + 'meta' => ['pagination' => ['total' => 1, 'count' => 1, 'per_page' => 200, 'current_page' => 1, 'total_pages' => 1]], + ], 200), + '*' => Http::response(['data' => []], 200), + ]); + + $this->get(route('dataspasial.skema', ['sektor' => 'pendidikan', 'skema' => 9])) + ->assertOk() + ->assertSee('Pratinjau Formulir Desa') + ->assertSee('wadah-pratinjau', false) + // Skrip editor memuat fungsi render pratinjau (R7). + ->assertSee('js/dataspasial/schema-editor.js', false); +}); + +it('menampilkan penjelasan pada pratinjau ketika sektor belum dipilih', function () { + Http::fake([ + '*/api/v1/config/*' => Http::response(['data' => []], 200), + '*/api/v1/dataspasial/sektor*' => Http::response([ + 'data' => [['type' => 'ds_sektor', 'id' => '1', + 'attributes' => ['kode' => 'pendidikan', 'nama' => 'Pendidikan', 'ikon' => null, 'status' => 'aktif']]], + 'meta' => ['pagination' => ['total' => 1, 'count' => 1, 'per_page' => 200, 'current_page' => 1, 'total_pages' => 1]], + ], 200), + '*' => Http::response(['data' => []], 200), + ]); + + $this->get(route('dataspasial.skema')) + ->assertOk() + ->assertSee('Pratinjau formulir akan muncul setelah sektor dipilih'); +}); + +// --------------------------------------------------------------------------- +// FR-015/Q16 — perbedaan perlakuan isian `manual` vs isian terpetakan +// +// ⚠️ `renderPratinjau()` berjalan di sisi klien (tidak diuji di sini). Yang +// dikunci test ini adalah KONDISI yang dibaca render tersebut: +// `terkunci = sumber && sumber !== 'manual'`. Bila aturan ini berubah, isian +// manual ikut berubah dari "bisa diisi" menjadi "terkunci" tanpa terdeteksi. +// --------------------------------------------------------------------------- + +it('membuat isian manual terbaca sebagai tidak terkunci di pratinjau', function () { + $hasil = app(SkemaEditorService::class)->petakanDaftarIsian([ + ['id' => '31', 'attributes' => fxIsian('jumlah_siswa', ['id' => 31, 'urutan' => 1])], + ]); + + // Tanpa pemetaan → `sumber` = 'manual' → renderMenampilkan input YANG BISA + // DIISI sebagai simulasi operator (Q16). + expect($hasil[0]['sumber'])->toBe('manual'); + + // Meniru kondisi yang dipakai `renderPratinjau()` di schema-editor.js. + $terkunci = (bool) ($hasil[0]['sumber'] && $hasil[0]['sumber'] !== 'manual'); + expect($terkunci)->toBeFalse(); +}); + +it('membuat isian terpetakan terbaca sebagai terkunci di pratinjau', function (string $sumber) { + $hasil = app(SkemaEditorService::class)->petakanDaftarIsian([ + ['id' => '31', 'attributes' => fxIsian('jumlah_siswa', ['id' => 31, 'urutan' => 1, 'pemetaan' => [ + ['id' => 1, 'sumber' => $sumber, 'rujukan_sumber' => 'jumlah_siswa', 'aturan_hitung' => null], + ]])], + ]); + + $terkunci = (bool) ($hasil[0]['sumber'] && $hasil[0]['sumber'] !== 'manual'); + + expect($terkunci)->toBeTrue() + ->and($hasil[0]['sumber'])->toBe($sumber); +})->with(['openSID', 'dataPresisi', 'impor']); diff --git a/tests/Feature/DataSpasial/SkemaEditorSaklarTest.php b/tests/Feature/DataSpasial/SkemaEditorSaklarTest.php new file mode 100644 index 00000000..af3e410a --- /dev/null +++ b/tests/Feature/DataSpasial/SkemaEditorSaklarTest.php @@ -0,0 +1,298 @@ + 'https://api.example.test', + 'dataspasial.cache_ttl' => 300, + ]); + + Cache::flush(); + + Setting::updateOrCreate( + ['key' => 'database_gabungan_api_key'], + ['name' => 'API Key', 'value' => 'token-uji'] + ); + + $team = Team::firstOrCreate(['name' => 'admin skema us5'], ['menu' => []]); + setPermissionsTeamId($team->id); + + $role = Role::firstOrCreate(['name' => 'admin skema us5', 'team_id' => $team->id, 'guard_name' => 'web']); + $role->syncPermissions(array_map( + fn (string $n) => Permission::findOrCreate($n, 'web'), + ['dataspasial-skema-read', 'dataspasial-skema-write', 'dataspasial-skema-edit', 'dataspasial-skema-delete'] + )); + + $this->user = User::factory()->create(); + $this->user->forceFill(['name' => 'Uji Admin Skema US5'])->save(); + $this->user->teams()->attach($team->id, ['id_user' => $this->user->id, 'id_team' => $team->id]); + $this->user->unsetRelation('team')->load('team'); + $this->user->assignRole($role); + + test()->actingAs($this->user, 'web'); + + $this->urlDraf = route('dataspasial.skema.draf.store'); +}); + +/** + * @param array $ubah + * @return array + */ +function isianSaklar(array $ubah = []): array +{ + return array_merge([ + 'id' => null, + 'kode' => 'jumlah_siswa', + 'label' => 'Jumlah Siswa', + 'tipe' => 'angka', + 'wajib' => false, + 'satuan' => 'orang', + 'opsi' => null, + 'urutan' => 1, + 'popupPeta' => false, + 'sebagaiFilter' => false, + 'sebagaiIndikator' => false, + 'sektorSumber' => null, 'rujukan' => null, + ], $ubah); +} + +/** + * @param array> $isian + * @return array + */ +function drafSaklar(array $isian): array +{ + return ['sektor' => 'pendidikan', 'sektor_nama' => 'Pendidikan', 'isian' => $isian]; +} + +// --------------------------------------------------------------------------- +// FR-026 — keadaan awal nonaktif +// --------------------------------------------------------------------------- + +it('memilih keadaan awal tidak aktif bila penanda tidak dikirim API', function () { + $editor = app(SkemaEditorService::class); + + // API tidak mengirim satu pun field asumsi (U2 belum tersedia). + $hasil = $editor->petakanIsian(['id' => '31', 'attributes' => [ + 'kode' => 'jumlah_siswa', 'label' => 'Jumlah Siswa', 'tipe' => 'angka', 'urutan' => 1, + ]]); + + expect($hasil['popupPeta'])->toBeFalse() + ->and($hasil['sebagaiFilter'])->toBeFalse() + ->and($hasil['sebagaiIndikator'])->toBeFalse(); +}); + +it('membaca penanda tampilan dari API bila sudah tersedia', function () { + $editor = app(SkemaEditorService::class); + + $hasil = $editor->petakanIsian(['id' => '31', 'attributes' => [ + 'kode' => 'luas_ha', 'label' => 'Luas', 'tipe' => 'angka', 'urutan' => 1, + 'tampil_popup' => true, 'tampil_filter' => false, 'tampil_indikator' => true, + ]]); + + expect($hasil['popupPeta'])->toBeTrue() + ->and($hasil['sebagaiFilter'])->toBeFalse() + ->and($hasil['sebagaiIndikator'])->toBeTrue(); +}); + +// --------------------------------------------------------------------------- +// FR-027 — independen satu per satu +// --------------------------------------------------------------------------- + +it('mem underpinned setiap saklar secara independen', function () { + $editor = app(SkemaEditorService::class); + + // Satu isian hanya popup peta; yang lain hanya indikator. + $a = $editor->petakanIsian(['id' => '1', 'attributes' => ['kode' => 'a', 'tipe' => 'teks', 'urutan' => 1, + 'tampil_popup' => true, 'tampil_filter' => false, 'tampil_indikator' => false]]); + $b = $editor->petakanIsian(['id' => '2', 'attributes' => ['kode' => 'b', 'tipe' => 'teks', 'urutan' => 2, + 'tampil_popup' => false, 'tampil_filter' => false, 'tampil_indikator' => true]]); + + expect($a['popupPeta'])->toBeTrue() + ->and($a['sebagaiFilter'])->toBeFalse() + ->and($a['sebagaiIndikator'])->toBeFalse() + ->and($b['popupPeta'])->toBeFalse() + ->and($b['sebagaiIndikator'])->toBeTrue(); +}); + +it('mengaktifkan ketiga saklar secara bersamaan', function () { + $editor = app(SkemaEditorService::class); + + $hasil = $editor->petakanIsian(['id' => '1', 'attributes' => ['kode' => 'a', 'tipe' => 'teks', 'urutan' => 1, + 'tampil_popup' => true, 'tampil_filter' => true, 'tampil_indikator' => true]]); + + expect($hasil['popupPeta'])->toBeTrue() + ->and($hasil['sebagaiFilter'])->toBeTrue() + ->and($hasil['sebagaiIndikator'])->toBeTrue(); +}); + +// --------------------------------------------------------------------------- +// FR-028 — ikut tersimpan & kembali +// --------------------------------------------------------------------------- + +it('memetakan nama internal ke nama asumsi kontrak saat menyusun draf', function () { + $editor = app(SkemaEditorService::class); + + $payload = $editor->composeDrafPayload([ + [ + 'kode' => 'luas_ha', + 'label' => 'Luas', + 'tipe' => 'angka', + 'urutan' => 1, + // Nama INTERNAL (dari view/JS)… + 'popupPeta' => true, + 'sebagaiFilter' => false, + 'sebagaiIndikator' => true, + ], + ], ['sektor' => 'pendidikan']); + + // …berubah menjadi NAMA ASUMSI kontrak, dan TIDAK ADA di berkas lain (R12). + expect($payload['isian'][0]['tampil_di_popup'])->toBeTrue() + ->and($payload['isian'][0]['tampil_di_filter'])->toBeFalse() + ->and($payload['isian'][0]['tampil_di_indikator'])->toBeTrue(); +}); + +it('menolak nama asumsi kontrak sebagai input dan mengabaikannya diam-diam', function () { + $editor = app(SkemaEditorService::class); + + // ⚠️ R12: `composeDrafPayload()` HANYA menerima NAMA INTERNAL. Bila ada + // pemanggil yang mengirim nama asumsi (`popup_peta` dkk), nilainya harus + // diabaikan — bukan diam-diam diterima, karena itu membuat asumsi + // _resolution scattering di luar satu berkas. + $payload = $editor->composeDrafPayload([ + ['kode' => 'a', 'label' => 'A', 'tipe' => 'teks', 'urutan' => 1, + 'tampil_popup' => true, 'sebagai_filter' => true, 'tampil_indikator' => true], + ], ['sektor' => 'pendidikan']); + + expect($payload['isian'][0]['tampil_di_popup'])->toBeFalse() + ->and($payload['isian'][0]['tampil_di_filter'])->toBeFalse() + ->and($payload['isian'][0]['tampil_di_indikator'])->toBeFalse(); +}); + +it('menyertakan ketiga penanda pada badan draf saat menyimpan', function () { + Http::fake(['*' => Http::response([ + 'data' => ['type' => 'ds_skema_versi', 'id' => '9', 'attributes' => ['nomor_versi' => 1]], + ], 201)]); + + $this->post($this->urlDraf, drafSaklar([ + isianSaklar(['popupPeta' => 1, 'sebagaiFilter' => 0, 'sebagaiIndikator' => 1]), + ]))->assertSessionHasNoErrors(); + + Http::assertSent(function ($request) { + $body = (string) $request->body(); + + return str_contains($request->url(), '/dataspasial/skema') + && (bool) preg_match('/"tampil_di_popup":\s*true/', $body) + && (bool) preg_match('/"tampil_di_filter":\s*false/', $body) + && (bool) preg_match('/"tampil_di_indikator":\s*true/', $body); + }); +}); + +// --------------------------------------------------------------------------- +// US5#4 — isian tetap ada meski ketiga saklar mati +// --------------------------------------------------------------------------- + +it('tetap menyimpan isian yang tidak tampil di mana pun', function () { + Http::fake(['*' => Http::response([ + 'data' => ['type' => 'ds_skema_versi', 'id' => '9', 'attributes' => ['nomor_versi' => 1]], + ], 201)]); + + $this->post($this->urlDraf, drafSaklar([ + isianSaklar(['popupPeta' => 0, 'sebagaiFilter' => 0, 'sebagaiIndikator' => 0]), + ]))->assertSessionHasNoErrors(); + + Http::assertSent(function ($request) { + $body = json_decode((string) $request->body(), true) ?? []; + $isian = $body['isian'][0] ?? []; + + // Isian tetap terkirim lengkap; hanya penanda tampilan yang mati. + return str_contains($request->url(), '/dataspasial/skema') + && ($isian['tampil_di_popup'] ?? null) === false + && ($isian['tampil_di_filter'] ?? null) === false + && ($isian['tampil_di_indikator'] ?? null) === false; + }); +}); + +// --------------------------------------------------------------------------- +// Saklar tampil pada tabel isian +// --------------------------------------------------------------------------- + +it('merender ketiga saklar pada tabel isian sesuai keadaan tersimpan', function () { + fakeEndpointEditor( + [fxSektor('1', 'pendidikan', 'Pendidikan')], + [fxRiwayatVersi('9', 1, 1, 'draf')], + fxSkemaAktif('9', 1, 'pendidikan', 'Pendidikan', 1, 'draf', [ + fxIsian('jumlah_siswa', ['id' => 31, 'urutan' => 1, 'tampil_popup' => true, 'tampil_indikator' => true]), + fxIsian('luas_ha', ['id' => 32, 'urutan' => 2]), + ]) + ); + + $content = $this->get(route('dataspasial.skema', ['sektor' => 'pendidikan', 'skema' => 9])) + ->assertOk() + ->getContent(); + + expect($content) + ->toContain('title="Tampil di popup peta"') + ->toContain('title="Tersedia sebagai filter"') + ->toContain('title="Dipakai sebagai indikator dasbor"') + ->toContain('class="input-popup-peta" checked') + ->toContain('class="input-indikator" checked') + ->toContain('class="input-filter"'); +}); + +it('menonaktifkan ketiga saklar pada skema yang sudah terbit', function () { + fakeEndpointEditor( + [fxSektor('1', 'pendidikan', 'Pendidikan')], + [fxRiwayatVersi('5', 1, 2, 'terbit')], + fxSkemaAktif('5', 1, 'pendidikan', 'Pendidikan', 2, 'terbit', [ + fxIsian('jumlah_siswa', ['id' => 31]), + ]) + ); + + $this->get(route('dataspasial.skema', ['sektor' => 'pendidikan', 'skema' => 5])) + ->assertOk() + ->assertSee('Skema versi terbit hanya dapat dibaca'); + + // FR-008a — baris yang benar-benar DIRENDER tidak boleh memuat tombol + // pengubah. ⚠️pemeriksaan dibatasi pada isi ``: `