From be2bfedc48b4d73932193d44a72a7b46cc7b440b Mon Sep 17 00:00:00 2001 From: Ahmad Afandi Date: Wed, 14 Oct 2026 15:14:38 +0700 Subject: [PATCH 1/5] Klien API Satu Data, menu & peran --- .env.example | 7 + app/Enums/DataSpasialReason.php | 78 +++ app/Enums/DataSpasialStatus.php | 36 ++ app/Enums/Modul.php | 37 ++ .../DataSpasialUnavailableException.php | 42 ++ .../DasborDataSpasialController.php | 38 ++ .../DataSpasial/DataSpasialController.php | 51 ++ .../DataSpasial/ImporDataController.php | 36 ++ .../DataSpasial/MonitoringController.php | 39 ++ .../DataSpasial/PetaGabunganController.php | 37 ++ .../SkemaDataSpasialController.php | 41 ++ app/Services/BaseApiService.php | 5 +- app/Services/DataSpasialApiService.php | 550 ++++++++++++++++++ app/Services/DataSpasialResult.php | 204 +++++++ config/dataspasial.php | 61 ++ ..._10_14_000001_create_dataspasial_roles.php | 231 ++++++++ .../components/dataspasial-status.blade.php | 21 + .../views/dataspasial/dasbor/index.blade.php | 53 ++ resources/views/dataspasial/empty.blade.php | 15 + .../views/dataspasial/impor/index.blade.php | 54 ++ .../dataspasial/monitoring/index.blade.php | 55 ++ .../views/dataspasial/peta/index.blade.php | 39 ++ .../views/dataspasial/skema/index.blade.php | 55 ++ .../views/dataspasial/unavailable.blade.php | 20 + routes/web.php | 31 + tests/Browser/SmokeDataSpasialMenuTest.php | 94 +++ .../DataSpasial/DataSpasialApiServiceTest.php | 474 +++++++++++++++ .../DataSpasial/DataSpasialMenuAccessTest.php | 271 +++++++++ .../DataSpasial/DataSpasialResultTest.php | 127 ++++ .../DataSpasialRoleMigrationTest.php | 273 +++++++++ .../DataSpasialUnavailableTest.php | 211 +++++++ 31 files changed, 3285 insertions(+), 1 deletion(-) create mode 100644 app/Enums/DataSpasialReason.php create mode 100644 app/Enums/DataSpasialStatus.php create mode 100644 app/Exceptions/DataSpasialUnavailableException.php create mode 100644 app/Http/Controllers/DataSpasial/DasborDataSpasialController.php create mode 100644 app/Http/Controllers/DataSpasial/DataSpasialController.php create mode 100644 app/Http/Controllers/DataSpasial/ImporDataController.php create mode 100644 app/Http/Controllers/DataSpasial/MonitoringController.php create mode 100644 app/Http/Controllers/DataSpasial/PetaGabunganController.php create mode 100644 app/Http/Controllers/DataSpasial/SkemaDataSpasialController.php create mode 100644 app/Services/DataSpasialApiService.php create mode 100644 app/Services/DataSpasialResult.php create mode 100644 config/dataspasial.php create mode 100644 database/migrations/2026_10_14_000001_create_dataspasial_roles.php create mode 100644 resources/views/components/dataspasial-status.blade.php create mode 100644 resources/views/dataspasial/dasbor/index.blade.php create mode 100644 resources/views/dataspasial/empty.blade.php create mode 100644 resources/views/dataspasial/impor/index.blade.php create mode 100644 resources/views/dataspasial/monitoring/index.blade.php create mode 100644 resources/views/dataspasial/peta/index.blade.php create mode 100644 resources/views/dataspasial/skema/index.blade.php create mode 100644 resources/views/dataspasial/unavailable.blade.php create mode 100644 tests/Browser/SmokeDataSpasialMenuTest.php create mode 100644 tests/Feature/DataSpasial/DataSpasialApiServiceTest.php create mode 100644 tests/Feature/DataSpasial/DataSpasialMenuAccessTest.php create mode 100644 tests/Feature/DataSpasial/DataSpasialResultTest.php create mode 100644 tests/Feature/DataSpasial/DataSpasialRoleMigrationTest.php create mode 100644 tests/Feature/DataSpasial/DataSpasialUnavailableTest.php diff --git a/.env.example b/.env.example index 78ccc0d3c..393f4ed15 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 000000000..d141835f7 --- /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/Exceptions/DataSpasialUnavailableException.php b/app/Exceptions/DataSpasialUnavailableException.php new file mode 100644 index 000000000..aadd57a03 --- /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 000000000..ad65c658e --- /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 000000000..5fb1565fd --- /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 000000000..0b566665d --- /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 000000000..cbba09824 --- /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 000000000..58998df94 --- /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 000000000..3fdbdcf59 --- /dev/null +++ b/app/Http/Controllers/DataSpasial/SkemaDataSpasialController.php @@ -0,0 +1,41 @@ +dataspasial->listSkema($request->only([ + 'sektor_id', + 'status', + 'page[number]', + 'page[size]', + 'sort', + ])); + + return $result; + } +} diff --git a/app/Services/BaseApiService.php b/app/Services/BaseApiService.php index 86b70fc18..fcbf05806 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/DataSpasialApiService.php b/app/Services/DataSpasialApiService.php new file mode 100644 index 000000000..d50a9bbbe --- /dev/null +++ b/app/Services/DataSpasialApiService.php @@ -0,0 +1,550 @@ +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, opsional disaring per sektor. + * + * @param array $filters + */ + public function listSkema(array $filters = []): DataSpasialResult + { + return $this->request('GET', 'skema', $filters); + } + + /** + * Detail satu versi skema beserta isiannya. + */ + public function detailSkema(int $id): DataSpasialResult + { + 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 000000000..e61b77c7a --- /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/config/dataspasial.php b/config/dataspasial.php new file mode 100644 index 000000000..45b605b27 --- /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/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 000000000..9cf0cdb22 --- /dev/null +++ b/database/migrations/2026_10_14_000001_create_dataspasial_roles.php @@ -0,0 +1,231 @@ + 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 + { + $team = Team::firstOrCreate( + ['name' => $teamName], + ['menu' => $this->buildMenu($config['menu'])] + ); + + // Menu sudah terisi (mis. dikustomisasi admin) → jangan menimpa. + // Menu yang belum berisi submenu DataSpasial diperbaiki agar peran + // tetap punya navigasi setelah instalasi/pembaruan. + if ($this->submenuCount($team->menu) === 0) { + $team->update(['menu' => $this->buildMenu($config['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`. + * + * @param array $allowedMenus + * @return array> + */ + private function buildMenu(array $allowedMenus): array + { + $group = $this->dataspasialGroup(); + + if ($group === null) { + return []; + } + + $allowed = array_map( + fn (string $menu) => 'dataspasial-'.$menu, + $allowedMenus + ); + $allowed = array_flip($allowed); + + return [ + array_merge($group, [ + 'submenu' => array_values(array_filter( + $group['submenu'] ?? [], + fn (array $item) => isset($allowed[$item['permission'] ?? '']) + )), + ]), + ]; + } + + /** + * Jumlah submenu grup DataSpasial yang tersimpan pada team. + */ + private function submenuCount(mixed $menu): int + { + foreach ((array) $menu as $group) { + if (($group['text'] ?? null) === 'DataSpasial') { + return count($group['submenu'] ?? []); + } + } + + return 0; + } + + /** + * @return array|null + */ + private function dataspasialGroup(): ?array + { + foreach (Modul::Menu as $menu) { + if (($menu['text'] ?? null) === 'DataSpasial') { + return $menu; + } + } + + return null; + } +}; diff --git a/resources/views/components/dataspasial-status.blade.php b/resources/views/components/dataspasial-status.blade.php new file mode 100644 index 000000000..a44508be2 --- /dev/null +++ b/resources/views/components/dataspasial-status.blade.php @@ -0,0 +1,21 @@ +@props([ + 'result', +]) + +{{-- + Merender tiga keadaan hasil pemanggilan API Satu Data (FR-005). + + - `unavailable` → pesan "data sementara tidak tersedia" (FR-014) + - `empty` → keadaan kosong yang jelas (FR-015) + - `data` → $slot (kerangka konten menu) + + Halaman selalu merespons HTTP 200 sehingga kerangka halaman, sidebar, + dan navigasi tetap utuh. +--}} +@if ($result->isUnavailable()) + @include('dataspasial.unavailable') +@elseif ($result->isEmpty()) + @include('dataspasial.empty') +@else + {{ $slot }} +@endif diff --git a/resources/views/dataspasial/dasbor/index.blade.php b/resources/views/dataspasial/dasbor/index.blade.php new file mode 100644 index 000000000..754bdbeb8 --- /dev/null +++ b/resources/views/dataspasial/dasbor/index.blade.php @@ -0,0 +1,53 @@ +@extends('layouts.index') + +@section('title', $title) + +@section('content_header') +

{{ $title }}

+@stop + +@section('content') + @include('partials.breadcrumbs') + @include('partials.flash_message') + + +
+
+
+
+

Ringkasan DataSpasial

+
+
+

+ Kerangka menu. Agregat dan visualisasi penuh menjadi lingkup + fitur turunan dan dibangun di atas service + DataSpasialApiService tanpa mengubah kontraknya. +

+ +
+ + + + + + + + + + @foreach ($result->data as $item) + @php $attributes = $item['attributes'] ?? $item; @endphp + + + + + + @endforeach + +
SektorPeriodeTotal
{{ $attributes['sektor'] ?? $attributes['sektor_id'] ?? '-' }}{{ $attributes['periode'] ?? '-' }}{{ $attributes['total'] ?? '-' }}
+
+
+
+
+
+
+@stop diff --git a/resources/views/dataspasial/empty.blade.php b/resources/views/dataspasial/empty.blade.php new file mode 100644 index 000000000..f5ea0e4fb --- /dev/null +++ b/resources/views/dataspasial/empty.blade.php @@ -0,0 +1,15 @@ +{{-- + Keadaan kosong (FR-015): respons sukses tanpa data. + + Sengaja BERBEDA dari `unavailable.blade.php` — ini bukan kegagalan, + melainkan memang belum ada data. +--}} +
+
+ +
Belum ada data
+

+ Sumber data dapat diakses, tetapi belum ada isian yang perlu ditampilkan. +

+
+
diff --git a/resources/views/dataspasial/impor/index.blade.php b/resources/views/dataspasial/impor/index.blade.php new file mode 100644 index 000000000..5142d521c --- /dev/null +++ b/resources/views/dataspasial/impor/index.blade.php @@ -0,0 +1,54 @@ +@extends('layouts.index') + +@section('title', $title) + +@section('content_header') +

{{ $title }}

+@stop + +@section('content') + @include('partials.breadcrumbs') + @include('partials.flash_message') + + +
+
+
+
+

Daftar Fasilitas

+
+
+

+ Kerangka menu. Alur impor lengkap (template, validasi per baris, + ringkasan ditolak dan konflik) menjadi lingkup fitur turunan dan + dibangun di atas service DataSpasialApiService tanpa + mengubah kontraknya. +

+ +
+ + + + + + + + + + @foreach ($result->data as $item) + @php $attributes = $item['attributes'] ?? []; @endphp + + + + + + @endforeach + +
RujukanNamaSektor
{{ $attributes['rujukan_fasilitas'] ?? $item['id'] ?? '-' }}{{ $attributes['nama'] ?? '-' }}{{ $attributes['sektor'] ?? $attributes['sektor_id'] ?? '-' }}
+
+
+
+
+
+
+@stop diff --git a/resources/views/dataspasial/monitoring/index.blade.php b/resources/views/dataspasial/monitoring/index.blade.php new file mode 100644 index 000000000..6393913bb --- /dev/null +++ b/resources/views/dataspasial/monitoring/index.blade.php @@ -0,0 +1,55 @@ +@extends('layouts.index') + +@section('title', $title) + +@section('content_header') +

{{ $title }}

+@stop + +@section('content') + @include('partials.breadcrumbs') + @include('partials.flash_message') + + +
+
+
+
+

Status Adopsi Skema per Desa

+
+
+

+ Kerangka menu. Daftar entri audit lengkap menjadi lingkup fitur + turunan dan dibangun di atas service + DataSpasialApiService tanpa mengubah kontraknya. +

+ +
+ + + + + + + + + + + @foreach ($result->data as $item) + @php $attributes = $item['attributes'] ?? []; @endphp + + + + + + + @endforeach + +
DesaVersi SkemaStatus AdopsiPerkuliahan
{{ $attributes['config']['nama_desa'] ?? $attributes['nama_desa'] ?? '-' }}{{ $attributes['skema_versi_id'] ?? '-' }}{{ $attributes['status_adopsi'] ?? '-' }}{{ $attributes['perkuliahan'] ?? '-' }}
+
+
+
+
+
+
+@stop diff --git a/resources/views/dataspasial/peta/index.blade.php b/resources/views/dataspasial/peta/index.blade.php new file mode 100644 index 000000000..989627e86 --- /dev/null +++ b/resources/views/dataspasial/peta/index.blade.php @@ -0,0 +1,39 @@ +@extends('layouts.index') + +@section('title', $title) + +@section('content_header') +

{{ $title }}

+@stop + +@section('content') + @include('partials.breadcrumbs') + @include('partials.flash_message') + + +
+
+
+
+

Peta Gabungan

+
+
+

+ Kerangka menu. Peta geospasial penuh menjadi lingkup fitur + turunan dan dibangun di atas service + DataSpasialApiService tanpa mengubah kontraknya. +

+ +
+

FeatureCollection diterima

+

+ {{ count($result->data) }} + lapisan geospasial +

+
+
+
+
+
+
+@stop diff --git a/resources/views/dataspasial/skema/index.blade.php b/resources/views/dataspasial/skema/index.blade.php new file mode 100644 index 000000000..62ea21f68 --- /dev/null +++ b/resources/views/dataspasial/skema/index.blade.php @@ -0,0 +1,55 @@ +@extends('layouts.index') + +@section('title', $title) + +@section('content_header') +

{{ $title }}

+@stop + +@section('content') + @include('partials.breadcrumbs') + @include('partials.flash_message') + + +
+
+
+
+

Daftar Skema Versi

+
+
+

+ Kerangka menu. Pengelolaan draf skema, isian, dan penerbitan versi + menjadi lingkup fitur turunan dan dibangun di atas service + DataSpasialApiService tanpa mengubah kontraknya. +

+ +
+ + + + + + + + + + + @foreach ($result->data as $item) + @php $attributes = $item['attributes'] ?? []; @endphp + + + + + + + @endforeach + +
SektorNomor VersiStatusDiterbitkan
{{ $attributes['sektor']['nama'] ?? $attributes['sektor_id'] ?? '-' }}{{ $attributes['nomor_versi'] ?? '-' }}{{ $attributes['status'] ?? '-' }}{{ $attributes['diterbitkan_pada'] ?? '-' }}
+
+
+
+
+
+
+@stop diff --git a/resources/views/dataspasial/unavailable.blade.php b/resources/views/dataspasial/unavailable.blade.php new file mode 100644 index 000000000..85f410d22 --- /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 a0adc4581..67e3f1424 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,32 @@ 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'); + + 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 000000000..8b041298c --- /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/Feature/DataSpasial/DataSpasialApiServiceTest.php b/tests/Feature/DataSpasial/DataSpasialApiServiceTest.php new file mode 100644 index 000000000..7007a7acf --- /dev/null +++ b/tests/Feature/DataSpasial/DataSpasialApiServiceTest.php @@ -0,0 +1,474 @@ + 'https://api.example.test', + 'dataspasial.timeout' => 10, + 'dataspasial.cache_ttl' => 300, + ]); + + Cache::flush(); + + // Token mengikuti pengaturan database gabungan (R1). + Setting::updateOrCreate( + ['key' => 'database_gabungan_api_key'], + ['name' => 'API Key', 'value' => 'token-uji'] + ); +}); + +function service(): DataSpasialApiService +{ + return app(DataSpasialApiService::class); +} + +/** + * @return array + */ +function successPayload(array $items, ?int $total = null): array +{ + return [ + 'data' => $items, + 'meta' => ['pagination' => [ + 'total' => $total ?? count($items), + 'count' => count($items), + 'per_page' => 10, + 'current_page' => 1, + 'total_pages' => 1, + ]], + 'links' => ['self' => 'https://api.example.test/api/v1/dataspasial/sektor'], + ]; +} + +/** + * @return array + */ +function sektorItem(string $id = '1', string $kode = 'pendidikan'): array +{ + return [ + 'type' => 'ds_sektor', + 'id' => $id, + 'attributes' => ['kode' => $kode, 'nama' => ucfirst($kode), 'status' => 'aktif'], + ]; +} + +// --------------------------------------------------------------------------- +// T010 — Respons sukses +// --------------------------------------------------------------------------- + +it('mengembalikan data beserta meta dari respons sukses', function () { + Http::fake([ + '*/api/v1/dataspasial/sektor*' => Http::response(successPayload([sektorItem()]), 200), + ]); + + $result = service()->listSektor(); + + expect($result->isData())->toBeTrue() + ->and($result->status->value)->toBe('data') + ->and($result->reason)->toBeNull() + ->and($result->httpStatus)->toBe(200) + ->and($result->count())->toBe(1) + ->and($result->data[0]['type'])->toBe('ds_sektor') + ->and($result->data[0]['id'])->toBe('1') + ->and($result->data[0]['attributes']['kode'])->toBe('pendidikan') + ->and($result->pagination())->toBe([ + 'total' => 1, + 'count' => 1, + 'per_page' => 10, + 'current_page' => 1, + 'total_pages' => 1, + ]); +}); + +it('mengirim token dan batas waktu yang dikonfigurasi', function () { + config(['dataspasial.timeout' => 3]); + Http::fake(['*' => Http::response(successPayload([sektorItem()]), 200)]); + + service()->listSektor(); + + Http::assertSent(function (Request $request) { + return $request->hasHeader('Authorization', 'Bearer token-uji') + && $request->hasHeader('Accept', 'application/json'); + }); +}); + +it('meneruskan filter sebagai query parameter', function () { + Http::fake(['*' => Http::response(successPayload([sektorItem()]), 200)]); + + service()->listNilai(['sektor_id' => 1, 'periode' => '2025-06', 'page[size]' => 25]); + + Http::assertSent(fn (Request $request) => $request->url() === 'https://api.example.test/api/v1/dataspasial/nilai?sektor_id=1&periode=2025-06&page%5Bsize%5D=25'); +}); + +// --------------------------------------------------------------------------- +// T011 — Respons kosong +// --------------------------------------------------------------------------- + +it('menandai respons sukses tanpa data sebagai keadaan kosong', function () { + Http::fake([ + '*/api/v1/dataspasial/sektor*' => Http::response(successPayload([], 0), 200), + ]); + + $result = service()->listSektor(); + + expect($result->isEmpty())->toBeTrue() + ->and($result->status->value)->toBe('empty') + ->and($result->isUnavailable())->toBeFalse() + ->and($result->isData())->toBeFalse() + ->and($result->reason)->toBeNull() + ->and($result->data)->toBe([]) + ->and($result->count())->toBe(0); +}); + +// --------------------------------------------------------------------------- +// T012 — 503 modul belum terpasang +// --------------------------------------------------------------------------- + +it('memetakan 503 modul belum terpasang menjadi tidak tersedia', function () { + Http::fake([ + '*/api/v1/dataspasial/*' => Http::response([ + 'success' => false, + 'message' => 'Modul DataSpasial belum terpasang atau versinya tidak cocok', + ], 503), + ]); + + $result = service()->listSektor(); + + expect($result->isUnavailable())->toBeTrue() + ->and($result->status->value)->toBe('unavailable') + ->and($result->reason)->toBeInstanceOf(DataSpasialReason::class) + ->and($result->reason->value)->toBe('module_not_installed') + ->and($result->httpStatus)->toBe(503) + ->and($result->message)->toContain('belum terpasang'); +}); + +it('menyediakan pesan generik yang tidak membocorkan pesan API', function () { + Http::fake([ + '*/api/v1/dataspasial/*' => Http::response([ + 'success' => false, + 'message' => 'Modul DataSpasial belum terpasang atau versinya tidak cocok', + ], 503), + ]); + + $result = service()->listSektor(); + + expect($result->userMessage())->toBe('Data sementara tidak tersedia. Silakan coba lagi nanti.') + ->and($result->userMessage())->not->toContain('belum terpasang'); +}); + +// --------------------------------------------------------------------------- +// T013 — Timeout +// --------------------------------------------------------------------------- + +it('memetakan timeout menjadi tidak tersedia tanpa melempar exception', function () { + Http::fake(fn () => throw new ConnectionException('cURL error 28: Operation timed out after 10001 milliseconds')); + + $result = service()->listSektor(); + + expect($result->isUnavailable())->toBeTrue() + ->and($result->reason->value)->toBe('timeout') + ->and($result->httpStatus)->toBe(0) + ->and($result->data)->toBe([]); +}); + +it('memetakan kegagalan koneksi non-timeout secara terpisah', function () { + Http::fake(fn () => throw new ConnectionException('cURL error 6: Could not resolve host')); + + $result = service()->listSektor(); + + expect($result->isUnavailable())->toBeTrue() + ->and($result->reason->value)->toBe('connection_failed'); +}); + +it('menangani exception tak terduga tanpa melempar ke pemanggil', function () { + Http::fake(fn () => throw new RuntimeException('keadaan tidak terduga')); + + $result = service()->listSektor(); + + expect($result->isUnavailable())->toBeTrue() + ->and($result->reason->value)->toBe('connection_failed'); +}); + +// --------------------------------------------------------------------------- +// T014 — Konfigurasi tidak lengkap +// --------------------------------------------------------------------------- + +it('gagal aman dan tidak mengirim permintaan saat token database gabungan kosong', function () { + Setting::where('key', 'database_gabungan_api_key')->delete(); + Http::fake(); + + $result = service()->listSektor(); + + expect(service()->isConfigured())->toBeFalse() + ->and($result->isUnavailable())->toBeTrue() + ->and($result->reason->value)->toBe('not_configured') + ->and($result->httpStatus)->toBe(0); + + Http::assertNothingSent(); +}); + +it('gagal aman saat URL dasar database gabungan kosong', function () { + config(['app.databaseGabunganUrl' => '']); + Http::fake(); + + $result = service()->listSektor(); + + expect($result->isUnavailable())->toBeTrue() + ->and($result->reason->value)->toBe('not_configured'); + + Http::assertNothingSent(); +}); + +it('mengambil token dari Setting database gabungan', function () { + Setting::updateOrCreate( + ['key' => 'database_gabungan_api_key'], + ['name' => 'API Key', 'value' => 'token-dari-setting'] + ); + Http::fake(['*' => Http::response(successPayload([sektorItem()]), 200)]); + + $result = service()->listSektor(); + + expect($result->isData())->toBeTrue(); + Http::assertSent(fn (Request $request) => $request->hasHeader('Authorization', 'Bearer token-dari-setting')); +}); + +// --------------------------------------------------------------------------- +// T015 — Header identitas pemanggil (audit API-07) +// --------------------------------------------------------------------------- + +it('menyertakan identitas pengguna pada setiap permintaan baca', function () { + $user = User::first() ?? User::factory()->create(); + $user->forceFill(['name' => 'Budi Santoso'])->save(); + $this->actingAs($user, 'web'); + + Http::fake(['*' => Http::response(successPayload([sektorItem()]), 200)]); + + service()->listSektor(); + + Http::assertSent(fn (Request $request) => $request->hasHeader('X-OpenKab-User-Id', (string) $user->id) + && $request->hasHeader('X-OpenKab-User-Name', 'Budi Santoso')); +}); + +it('menyertakan identitas pengguna pada setiap permintaan tulis', function () { + $user = User::first() ?? User::factory()->create(); + $user->forceFill(['name' => 'Siti Aminah'])->save(); + $this->actingAs($user, 'web'); + + Http::fake(['*' => Http::response(['type' => 'ds_skema_versi', 'id' => '3', 'attributes' => []], 201)]); + + service()->buatDrafSkema(['sektor' => 'pendidikan', 'isian' => []]); + + Http::assertSent(fn (Request $request) => $request->hasHeader('X-OpenKab-User-Id', (string) $user->id) + && $request->hasHeader('X-OpenKab-User-Name', 'Siti Aminah')); +}); + +it('tetap mengirim header identitas dengan nilai kosong tanpa konteks pengguna', function () { + auth()->logout(); + Http::fake(['*' => Http::response(successPayload([sektorItem()]), 200)]); + + service()->listSektor(); + + Http::assertSent(fn (Request $request) => $request->hasHeader('X-OpenKab-User-Id', '') + && $request->hasHeader('X-OpenKab-User-Name', '')); +}); + +// --------------------------------------------------------------------------- +// T016 — Pemetaan status & error +// --------------------------------------------------------------------------- + +it('memetakan 401 dan 403 sebagai unauthorized', function (int $status) { + Http::fake(['*' => Http::response(['message' => 'ditolak'], $status)]); + + $result = service()->listSektor(); + + expect($result->reason->value)->toBe('unauthorized') + ->and($result->httpStatus)->toBe($status); +})->with([401, 403]); + +it('memetakan 404, 409, dan 422 sebagai client error', function (int $status) { + Http::fake(['*' => Http::response(['message' => 'masalah permintaan'], $status)]); + + $result = service()->listSektor(); + + expect($result->reason->value)->toBe('client_error') + ->and($result->httpStatus)->toBe($status); +})->with([404, 409, 422]); + +it('memetakan 500 sebagai layanan tidak tersedia', function () { + Http::fake(['*' => Http::response(['message' => 'server error'], 500)]); + + $result = service()->listSektor(); + + expect($result->reason->value)->toBe('http_5xx') + ->and($result->httpStatus)->toBe(500); +}); + +it('memetakan 400 dengan error_code versi skema tidak didukung', function () { + Http::fake(['*' => Http::response([ + 'success' => false, + 'message' => 'Versi skema 0 tidak didukung. Versi yang didukung: 1, 2, 3.', + 'error_code' => 'SchemaVersionUnsupported', + 'meta' => ['schema_version' => 0, 'supported_versions' => [1, 2, 3]], + ], 400)]); + + $result = service()->terbitkanSkema('pendidikan', ['catatan_perubahan' => 'x'], 0); + + expect($result->reason->value)->toBe('schema_version_unsupported') + ->and($result->httpStatus)->toBe(400); +}); + +it('tidak mengirim header versi skema bila tidak diminta', function () { + Http::fake(['*' => Http::response(successPayload([sektorItem()]), 200)]); + + service()->listSkema(); + + Http::assertSent(fn (Request $request) => ! $request->hasHeader('X-Schema-Version')); +}); + +it('meneruskan header versi skema pada operasi tulis bila diberikan', function () { + Http::fake(['*' => Http::response(['data' => ['type' => 'ds_skema_versi', 'id' => '3', 'attributes' => []]], 200)]); + + service()->terbitkanSkema('pendidikan', ['catatan_perubahan' => 'x'], 3); + + Http::assertSent(fn (Request $request) => $request->hasHeader('X-Schema-Version', '3')); +}); + +// --------------------------------------------------------------------------- +// T017 — Cache +// --------------------------------------------------------------------------- + +it('tidak memanggil API dua kali untuk baca identik dalam masa cache', function () { + Http::fake(['*' => Http::response(successPayload([sektorItem()]), 200)]); + + $first = service()->listSektor(); + $second = service()->listSektor(); + + expect($first->isData())->toBeTrue() + ->and($second->isData())->toBeTrue() + ->and($second->data)->toBe($first->data); + + Http::assertSentCount(1); +}); + +it('memakai cache untuk keadaan kosong juga', function () { + Http::fake(['*' => Http::response(successPayload([], 0), 200)]); + + service()->listSektor(); + $second = service()->listSektor(); + + expect($second->isEmpty())->toBeTrue(); + Http::assertSentCount(1); +}); + +it('tidak memakai cache untuk query berbeda', function () { + Http::fake(['*' => Http::response(successPayload([sektorItem()]), 200)]); + + service()->listSektor(['sektor_id' => 1]); + service()->listSektor(['sektor_id' => 2]); + + Http::assertSentCount(2); +}); + +it('tidak meng-cache hasil tidak tersedia', function () { + Http::fake(['*' => Http::response(['message' => 'mati'], 503)]); + + service()->listSektor(); + service()->listSektor(); + + Http::assertSentCount(2); +}); + +it('tidak meng-cache hasil timeout', function () { + // Closure fake yang melempar exception tidak tercatat sebagai "sent", + // sehingga jumlah pemanggilan dihitung lewat penghitung. + $attempts = 0; + Http::fake(function () use (&$attempts) { + $attempts++; + + throw new ConnectionException('cURL error 28: Operation timed out'); + }); + + service()->listSektor(); + service()->listSektor(); + + expect($attempts)->toBe(2); +}); + +it('mematikan cache bila ttl bernilai nol', function () { + config(['dataspasial.cache_ttl' => 0]); + Http::fake(['*' => Http::response(successPayload([sektorItem()]), 200)]); + + service()->listSektor(); + service()->listSektor(); + + Http::assertSentCount(2); +}); + +it('membuat cache baca tidak berlaku setelah operasi tulis', function (string $method) { + Http::fake(['*' => Http::response(successPayload([sektorItem()]), 200)]); + + service()->listSektor(); + Http::assertSentCount(1); + + match ($method) { + 'buatDrafSkema' => service()->buatDrafSkema(['sektor' => 'pendidikan', 'isian' => []]), + 'terbitkanSkema' => service()->terbitkanSkema('pendidikan', ['catatan_perubahan' => 'x']), + 'imporNilai' => service()->imporNilai(['kode_desa' => '5102012001', 'nomor_versi' => 3, 'periode' => '2025-06', 'fasilitas' => []]), + }; + + Http::assertSentCount(2); + + // Cache lama tidak boleh dipakai lagi -> request ketiga benar-benar dikirim. + service()->listSektor(); + + Http::assertSentCount(3); +})->with(['buatDrafSkema', 'terbitkanSkema', 'imporNilai']); + +it('tidak meng-invalidasi cache bila operasi tulis gagal', function () { + $attempts = 0; + Http::fake(function () use (&$attempts) { + $attempts++; + + return $attempts === 1 + ? Http::response(successPayload([sektorItem()]), 200) + : Http::response(['message' => 'gagal'], 500); + }); + + service()->listSektor(); + $write = service()->terbitkanSkema('pendidikan', ['catatan_perubahan' => 'x']); + + expect($write->isUnavailable())->toBeTrue(); + + // Cache baca hasil kondisi sukses sebelumnya masih berlaku. + $third = service()->listSektor(); + + expect($third->isData())->toBeTrue() + ->and($attempts)->toBe(2); +}); + +it('memakai kunci cache yang berbeda untuk endpoint berbeda', function () { + $service = service(); + + expect($service->cacheKey('/sektor'))->not->toBe($service->cacheKey('/nilai')) + ->and($service->cacheKey('/sektor', ['a' => 1]))->not->toBe($service->cacheKey('/sektor', ['a' => 2])) + ->and($service->cacheKey('/sektor', ['a' => 1, 'b' => 2])) + ->toBe($service->cacheKey('/sektor', ['b' => 2, 'a' => 1])); +}); diff --git a/tests/Feature/DataSpasial/DataSpasialMenuAccessTest.php b/tests/Feature/DataSpasial/DataSpasialMenuAccessTest.php new file mode 100644 index 000000000..6793f3f2d --- /dev/null +++ b/tests/Feature/DataSpasial/DataSpasialMenuAccessTest.php @@ -0,0 +1,271 @@ + $permissions + */ +function actingAsWithTeam(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 $name) => Permission::findOrCreate($name, '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); + + test()->actingAs($user, 'web'); + + return $user; +} + +/** + * @return array + */ +function permissionNames(string $menu, array $abilities): array +{ + return array_map(fn (string $ability) => "dataspasial-{$menu}-{$ability}", $abilities); +} + +function fakeApiSuccess(): void +{ + Http::fake([ + // Konfigurasi wilayah yang dipakai sidebar adminlte (MenuListener). + '*/api/v1/config/kabupaten*' => Http::response(['data' => []], 200), + '*/api/v1/config/kecamatan*' => Http::response(['data' => []], 200), + '*/api/v1/config/desa*' => Http::response(['data' => []], 200), + // Endpoint DataSpasial. + '*' => Http::response([ + 'data' => [['type' => 'ds_sektor', 'id' => '1', 'attributes' => ['kode' => 'pendidikan', 'nama' => 'Pendidikan']]], + 'meta' => ['pagination' => ['total' => 1, 'count' => 1, 'per_page' => 10, 'current_page' => 1, 'total_pages' => 1]], + 'links' => [], + ], 200), + ]); +} + +beforeEach(function () { + config([ + 'app.databaseGabunganUrl' => 'https://api.example.test', + 'dataspasial.cache_ttl' => 300, + ]); + + Setting::updateOrCreate( + ['key' => 'database_gabungan_api_key'], + ['name' => 'API Key', 'value' => 'token-uji'] + ); + + fakeApiSuccess(); +}); + +// --------------------------------------------------------------------------- +// T026 — Akses langsung lewat URL ditolak bagi peran tanpa izin (FR-013) +// --------------------------------------------------------------------------- + +it('menolak akses Analis OPD ke Skema DataSpasial dan Impor Data', function (string $route) { + actingAsWithTeam('analis opd', permissionNames('peta', ['read'])); + + $this->get(route($route))->assertForbidden(); +})->with(['dataspasial.skema', 'dataspasial.impor']); + +it('mengizinkan Admin Skema membuka Skema DataSpasial', function () { + actingAsWithTeam('admin skema', permissionNames('skema', ['read', 'write', 'edit', 'delete'])); + + $this->get(route('dataspasial.skema'))->assertOk(); +}); + +it('mengizinkan Operator Impor membuka Impor Data', function () { + actingAsWithTeam('operator impor', permissionNames('impor', ['read', 'write'])); + + $this->get(route('dataspasial.impor'))->assertOk(); +}); + +it('mengizinkan Analis OPD membuka ketiga halaman baca', function (string $route) { + actingAsWithTeam('analis opd', array_merge( + permissionNames('peta', ['read']), + permissionNames('dasbor', ['read']), + permissionNames('monitoring', ['read']), + )); + + $this->get(route($route))->assertOk(); +})->with(['dataspasial.peta', 'dataspasial.dasbor', 'dataspasial.monitoring']); + +it('menolak seluruh halaman DataSpasial bagi pengguna tanpa peran DataSpasial', function (string $route) { + actingAsWithTeam('staff biasa', ['penduduk-read']); + + $this->get(route($route))->assertForbidden(); +})->with([ + 'dataspasial.skema', + 'dataspasial.peta', + 'dataspasial.dasbor', + 'dataspasial.impor', + 'dataspasial.monitoring', +]); + +it('menolak Admin Skema membuka Impor Data', function () { + actingAsWithTeam('admin skema', permissionNames('skema', ['read', 'write', 'edit', 'delete'])); + + $this->get(route('dataspasial.impor'))->assertForbidden(); +}); + +it('menolak Operator Impor membuka Skema DataSpasial', function () { + actingAsWithTeam('operator impor', permissionNames('impor', ['read', 'write'])); + + $this->get(route('dataspasial.skema'))->assertForbidden(); +}); + +// --------------------------------------------------------------------------- +// T027 — Menu tidak tampil bagi peran tanpa izin +// --------------------------------------------------------------------------- + +it('menyembunyikan menu Skema dan Impor dari Analis OPD', function () { + // Menu grup disimpan per team; seeder hanya memasukkan submenu yang diizinkan. + $menu = dataspasialMenuFor([ + 'dataspasial-peta-read', + 'dataspasial-dasbor-read', + 'dataspasial-monitoring-read', + ]); + + $visibleTexts = array_column($menu[0]['submenu'], 'text'); + + expect($visibleTexts)->toContain('Peta Gabungan', 'Dasbor DataSpasial', 'Monitoring') + ->and($visibleTexts)->not->toContain('Skema DataSpasial') + ->and($visibleTexts)->not->toContain('Impor Data'); +}); + +it('menyembunyikan menu Impor dari Admin Skema dan menu Skema dari Operator Impor', function (array $allowed, array $hidden) { + $menu = dataspasialMenuFor($allowed); + $visibleTexts = array_column($menu[0]['submenu'], 'text'); + + foreach ($hidden as $text) { + expect($visibleTexts)->not->toContain($text); + } +})->with([ + [ + ['dataspasial-skema-read', 'dataspasial-peta-read', 'dataspasial-dasbor-read', 'dataspasial-monitoring-read'], + ['Impor Data'], + ], + [ + ['dataspasial-impor-read', 'dataspasial-peta-read', 'dataspasial-dasbor-read', 'dataspasial-monitoring-read'], + ['Skema DataSpasial'], + ], +]); + +it('menampilkan kelima menu DataSpasial pada administrator', function () { + $group = dataspasialMenuGroup(); + + expect(array_column($group['submenu'], 'text'))->toBe([ + 'Skema DataSpasial', + 'Peta Gabungan', + 'Dasbor DataSpasial', + 'Impor Data', + 'Monitoring', + ]); +}); + +// --------------------------------------------------------------------------- +// T028 — Struktur menu di App\Enums\Modul::Menu +// --------------------------------------------------------------------------- + +it('mendaftarkan grup DataSpasial dengan lima submenu berizin', function () { + $group = dataspasialMenuGroup(); + + expect($group['text'])->toBe('DataSpasial') + ->and($group['permission'])->toBe('dataspasial') + ->and(count($group['submenu']))->toBe(5); + + foreach ($group['submenu'] as $item) { + expect($item['permission'])->toStartWith('dataspasial-') + ->and($item['url'])->toStartWith('dataspasial/') + ->and($item['text'])->not->toBeEmpty(); + } +}); + +it('memetakan setiap submenu ke URL yang sesuai', function () { + $group = dataspasialMenuGroup(); + $actual = []; + + foreach ($group['submenu'] as $item) { + $actual[$item['text']] = $item['url']; + } + + expect($actual)->toBe([ + 'Skema DataSpasial' => 'dataspasial/skema', + 'Peta Gabungan' => 'dataspasial/peta', + 'Dasbor DataSpasial' => 'dataspasial/dasbor', + 'Impor Data' => 'dataspasial/impor', + 'Monitoring' => 'dataspasial/monitoring', + ]); +}); + +it('menempatkan grup DataSpasial di dalam App\Enums\Modul::Menu', function () { + $texts = array_column(Modul::Menu, 'text'); + + expect($texts)->toContain('DataSpasial'); +}); + +/** + * Ambil grup menu DataSpasial dari App\Enums\Modul::Menu. + * + * @return array + */ +function dataspasialMenuGroup(): array +{ + foreach (Modul::Menu as $menu) { + if (($menu['text'] ?? null) === 'DataSpasial') { + return $menu; + } + } + + test()->fail('Grup menu DataSpasial tidak ditemukan di App\Enums\Modul::Menu.'); +} + +/** + * Bangun grup menu DataSpasial berisi hanya submenu yang diizinkan. + * + * @param array $permissions + * @return array> + */ +function dataspasialMenuFor(array $permissions): array +{ + $group = dataspasialMenuGroup(); + + return [ + array_merge($group, [ + 'submenu' => array_values(array_filter( + $group['submenu'], + fn (array $item) => in_array($item['permission'].'-read', $permissions, true) + )), + ]), + ]; +} diff --git a/tests/Feature/DataSpasial/DataSpasialResultTest.php b/tests/Feature/DataSpasial/DataSpasialResultTest.php new file mode 100644 index 000000000..bfce425b7 --- /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 000000000..b00f11f55 --- /dev/null +++ b/tests/Feature/DataSpasial/DataSpasialRoleMigrationTest.php @@ -0,0 +1,273 @@ +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'); +}); + +// --------------------------------------------------------------------------- +// 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 000000000..ff8e6c252 --- /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'); +}); From 6f813ad60b981ef551878f6fc548b219b57c8606 Mon Sep 17 00:00:00 2001 From: Ahmad Afandi Date: Wed, 14 Oct 2026 15:50:10 +0700 Subject: [PATCH 2/5] perbaikan menu hak akses --- ..._10_14_000001_create_dataspasial_roles.php | 109 ++++++++++++++---- .../DataSpasialRoleMigrationTest.php | 67 +++++++++++ 2 files changed, 155 insertions(+), 21 deletions(-) diff --git a/database/migrations/2026_10_14_000001_create_dataspasial_roles.php b/database/migrations/2026_10_14_000001_create_dataspasial_roles.php index 9cf0cdb22..fb8a4207c 100644 --- a/database/migrations/2026_10_14_000001_create_dataspasial_roles.php +++ b/database/migrations/2026_10_14_000001_create_dataspasial_roles.php @@ -123,16 +123,19 @@ private function ensureAdministratorAccess(): void */ private function ensureRole(string $teamName, array $config): void { + $menu = $this->buildMenu($config['menu'], $config['abilities']); + $team = Team::firstOrCreate( ['name' => $teamName], - ['menu' => $this->buildMenu($config['menu'])] + ['menu' => $menu] ); - // Menu sudah terisi (mis. dikustomisasi admin) → jangan menimpa. - // Menu yang belum berisi submenu DataSpasial diperbaiki agar peran - // tetap punya navigasi setelah instalasi/pembaruan. - if ($this->submenuCount($team->menu) === 0) { - $team->update(['menu' => $this->buildMenu($config['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); @@ -174,10 +177,19 @@ private function permissionNames(array $abilities): array * 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 + private function buildMenu(array $allowedMenus, array $abilities): array { $group = $this->dataspasialGroup(); @@ -185,34 +197,89 @@ private function buildMenu(array $allowedMenus): array return []; } - $allowed = array_map( + $allowed = array_flip(array_map( fn (string $menu) => 'dataspasial-'.$menu, $allowedMenus - ); - $allowed = array_flip($allowed); + )); + + $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 [ - array_merge($group, [ - 'submenu' => array_values(array_filter( - $group['submenu'] ?? [], - fn (array $item) => isset($allowed[$item['permission'] ?? '']) - )), - ]), + $this->withAbilityFlags($groupItem, array_values(array_unique($groupAbilities))), ]; } /** - * Jumlah submenu grup DataSpasial yang tersimpan pada team. + * 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 submenuCount(mixed $menu): int + private function needsMenuRepair(mixed $menu): bool { foreach ((array) $menu as $group) { - if (($group['text'] ?? null) === 'DataSpasial') { - return count($group['submenu'] ?? []); + 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 0; + return true; } /** diff --git a/tests/Feature/DataSpasial/DataSpasialRoleMigrationTest.php b/tests/Feature/DataSpasial/DataSpasialRoleMigrationTest.php index b00f11f55..c7f913d68 100644 --- a/tests/Feature/DataSpasial/DataSpasialRoleMigrationTest.php +++ b/tests/Feature/DataSpasial/DataSpasialRoleMigrationTest.php @@ -186,6 +186,73 @@ function abilities(string $menu, array $abilities): array ->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 // --------------------------------------------------------------------------- From 7cc36145d9d255b8bca2f872592bd1ea1eac35be Mon Sep 17 00:00:00 2001 From: Ahmad Afandi Date: Thu, 15 Oct 2026 11:11:09 +0700 Subject: [PATCH 3/5] UI/UX sudah OK --- app/Enums/StatusSkemaEditor.php | 75 ++ app/Enums/TipeIsian.php | 52 + .../SkemaDataSpasialController.php | 337 ++++++- .../DataSpasial/StoreDrafSkemaRequest.php | 200 ++++ .../DataSpasial/StoreSektorRequest.php | 61 ++ .../DataSpasial/TerbitSkemaRequest.php | 66 ++ .../DataSpasial/UpdateSektorRequest.php | 59 ++ app/Services/ArtikelService.php | 1 - app/Services/CaptchaService.php | 47 +- app/Services/ConfigApiService.php | 18 +- app/Services/DataSpasialApiService.php | 82 +- app/Services/IlluminatePaginatorAdapter.php | 4 +- app/Services/OtpService.php | 73 +- app/Services/SitemapService.php | 13 +- app/Services/SkemaEditorService.php | 431 ++++++++ app/Services/TwoFactorService.php | 2 +- database/factories/UserFactory.php | 10 +- public/js/dataspasial/schema-editor.js | 946 ++++++++++++++++++ .../views/dataspasial/skema/index.blade.php | 130 ++- .../skema/partials/form-isian.blade.php | 137 +++ .../skema/partials/panel-sektor.blade.php | 177 ++++ .../skema/partials/panel-skema.blade.php | 78 ++ .../skema/partials/pilih-sumber.blade.php | 69 ++ .../skema/partials/pratinjau.blade.php | 51 + .../skema/partials/tabel-isian.blade.php | 182 ++++ routes/web.php | 22 + tests/Browser/SmokeSkemaEditorTest.php | 123 +++ .../DataSpasial/DataSpasialApiServiceTest.php | 88 ++ .../DataSpasial/DataSpasialMenuAccessTest.php | 93 ++ tests/Feature/DataSpasial/Fixtures.php | 225 +++++ .../DataSpasial/SkemaEditorDrafTest.php | 320 ++++++ .../DataSpasial/SkemaEditorEditSektorTest.php | 250 +++++ .../DataSpasial/SkemaEditorIsianTest.php | 423 ++++++++ .../DataSpasial/SkemaEditorPemetaanTest.php | 256 +++++ .../DataSpasial/SkemaEditorPratinjauTest.php | 334 +++++++ .../DataSpasial/SkemaEditorSaklarTest.php | 281 ++++++ .../DataSpasial/SkemaEditorSektorTest.php | 911 +++++++++++++++++ .../DataSpasial/SkemaEditorTerbitTest.php | 196 ++++ .../DataSpasial/SkemaEditorValidationTest.php | 309 ++++++ tests/Pest.php | 8 + tests/TestCase.php | 3 +- 41 files changed, 7002 insertions(+), 141 deletions(-) create mode 100644 app/Enums/StatusSkemaEditor.php create mode 100644 app/Enums/TipeIsian.php create mode 100644 app/Http/Requests/DataSpasial/StoreDrafSkemaRequest.php create mode 100644 app/Http/Requests/DataSpasial/StoreSektorRequest.php create mode 100644 app/Http/Requests/DataSpasial/TerbitSkemaRequest.php create mode 100644 app/Http/Requests/DataSpasial/UpdateSektorRequest.php create mode 100644 app/Services/SkemaEditorService.php create mode 100644 public/js/dataspasial/schema-editor.js create mode 100644 resources/views/dataspasial/skema/partials/form-isian.blade.php create mode 100644 resources/views/dataspasial/skema/partials/panel-sektor.blade.php create mode 100644 resources/views/dataspasial/skema/partials/panel-skema.blade.php create mode 100644 resources/views/dataspasial/skema/partials/pilih-sumber.blade.php create mode 100644 resources/views/dataspasial/skema/partials/pratinjau.blade.php create mode 100644 resources/views/dataspasial/skema/partials/tabel-isian.blade.php create mode 100644 tests/Browser/SmokeSkemaEditorTest.php create mode 100644 tests/Feature/DataSpasial/Fixtures.php create mode 100644 tests/Feature/DataSpasial/SkemaEditorDrafTest.php create mode 100644 tests/Feature/DataSpasial/SkemaEditorEditSektorTest.php create mode 100644 tests/Feature/DataSpasial/SkemaEditorIsianTest.php create mode 100644 tests/Feature/DataSpasial/SkemaEditorPemetaanTest.php create mode 100644 tests/Feature/DataSpasial/SkemaEditorPratinjauTest.php create mode 100644 tests/Feature/DataSpasial/SkemaEditorSaklarTest.php create mode 100644 tests/Feature/DataSpasial/SkemaEditorSektorTest.php create mode 100644 tests/Feature/DataSpasial/SkemaEditorTerbitTest.php create mode 100644 tests/Feature/DataSpasial/SkemaEditorValidationTest.php diff --git a/app/Enums/StatusSkemaEditor.php b/app/Enums/StatusSkemaEditor.php new file mode 100644 index 000000000..7132306b2 --- /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 000000000..257fa0dc8 --- /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/Http/Controllers/DataSpasial/SkemaDataSpasialController.php b/app/Http/Controllers/DataSpasial/SkemaDataSpasialController.php index 3fdbdcf59..9b7801716 100644 --- a/app/Http/Controllers/DataSpasial/SkemaDataSpasialController.php +++ b/app/Http/Controllers/DataSpasial/SkemaDataSpasialController.php @@ -4,18 +4,39 @@ namespace App\Http\Controllers\DataSpasial; +use App\Enums\StatusSkemaEditor; +use App\Enums\TipeIsian; +use App\Http\Requests\DataSpasial\StoreDrafSkemaRequest; +use App\Http\Requests\DataSpasial\StoreSektorRequest; +use App\Http\Requests\DataSpasial\TerbitSkemaRequest; +use App\Http\Requests\DataSpasial\UpdateSektorRequest; +use App\Services\DataSpasialApiService; use App\Services\DataSpasialResult; +use App\Services\SkemaEditorService; +use Illuminate\Contracts\View\View; +use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; +use Illuminate\Support\Facades\Log; /** - * Menu Skema DataSpasial (Admin Skema). + * Menu Skema DataSpasial — editor skema kabupaten (KAB-02 / #1297). * - * Kerangka menu pada fase fondasi ini. Editor sektor/pemetaan skema menjadi - * lingkup KAB-02 (OpenSID/OpenKab#1297) dan dibangun di atas service ini - * tanpa mengubah kontraknya (FR-018). + * Ketentuan yang berlaku: + * - HANYA membaca/menulis lewat `DataSpasialApiService` (FR-004/SC-010); + * - daftar skema baru muncul SETELAH admin memilih sektor (FR-007a); + * - penerbitan memakai KODE sektor pada path, bukan id (FR-041); + * - kegagalan API diterjemahkan menjadi pesan generik, bukan halaman error + * (FR-005/FR-021). */ class SkemaDataSpasialController extends DataSpasialController { + public function __construct( + DataSpasialApiService $dataspasial, + private readonly SkemaEditorService $editor, + ) { + parent::__construct($dataspasial); + } + protected function pageTitle(): string { return 'Skema DataSpasial'; @@ -26,16 +47,308 @@ protected function viewName(): string return 'dataspasial.skema.index'; } + /** + * Data awal untuk kerangka & komponen tiga-keadaan. + * + * Halaman editor memerlukan daftar sektor sebagai sumber kebenarannya, + * sehingga `fetch()` membaca sektor (bukan daftar skema seperti kerangka + * sebelumnya). + */ protected function fetch(Request $request): DataSpasialResult { - $result = $this->dataspasial->listSkema($request->only([ - 'sektor_id', - 'status', - 'page[number]', - 'page[size]', - 'sort', - ])); + return $this->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); + + // ⚠️ FR-007a-1 — endpoint daftar skema yang membutuhkan parameter sektor + // DIPASTIKAN GAGAL bila dipanggil tanpa sektor. Karena itu setelah + // kunjungan pertama ia **tidak boleh** dipanggil sama sekali, bukan + // sekadar "dipanggil lalu hasilnya diabaikan". + $resultSkema = null; + $skemaSektor = []; + $skemaTerpilih = null; + $isian = []; + $resultDetail = null; + + if ($sektorTerpilih !== null) { + // ⚠️ Q9 — daftar skema diambil dari endpoint RIWAYAT VERSI. + // `GET /skema?sektor=` hanya mengembalikan SATU versi (aktif) sehingga + // tidak bisa menjadi sumber daftar. Parameternya KODE sektor. + $resultRiwayat = $this->dataspasial->riwayatSkema([ + 'sektor' => $sektorTerpilih['kode'], + ]); + + $skemaSektor = array_map( + fn (array $item): array => $this->editor->petakanRiwayatVersi($item), + $resultRiwayat->data + ); + + // Urut dari versi terbaru (API sudah mengurutkan `nomor_versi` DESC). + $skemaTerpilih = $this->skemaTerpilih($request, $skemaSektor); + + // ⚠️ K2 — isian hanya diambil bila sebuah versi benar-benar dipilih. + if ($skemaTerpilih !== null) { + // ⚠️ K1: endpoint versi aktif memakai parameter `sektor` (WAJIB), + // dan responsnya sudah memuat `isian[]` + `pemetaan[]`. + $resultSkema = $this->dataspasial->listSkema([ + 'sektor' => $sektorTerpilih['id'], + ]); + + [$isian, $resultDetail] = $this->muatIsian($resultSkema); + } else { + $isian = []; + $resultDetail = null; + } + } + + // Status global: bila sektor tidak dapat diperoleh, seluruh tombol mati. + if ($resultSektor->isUnavailable()) { + $statusEditor = StatusSkemaEditor::fromValue(StatusSkemaEditor::BelumTersedia); + } elseif ($skemaTerpilih === null) { + $statusEditor = $this->editor->statusEditor(null, $skemaSektor); + } else { + $statusEditor = $this->editor->statusEditor($skemaTerpilih, $skemaSektor); + } + + return view($this->viewName(), [ + 'title' => $this->pageTitle(), + 'result' => $resultSektor, + 'resultSektor' => $resultSektor, + 'resultSkema' => $resultSkema, + 'resultDetail' => $resultDetail, + 'daftarSektor' => $daftarSektor, + 'sektorTerpilih' => $sektorTerpilih, + 'skemaSektor' => $skemaSektor, + 'skemaTerpilih' => $skemaTerpilih, + 'isian' => $isian, + 'statusEditor' => $statusEditor, + 'daftarSektorSumber' => $daftarSektor, + 'tipeIsian' => TipeIsian::class, + ]); + } + + /** + * Tambah sektor baru (FR-007d). + * + * ⚠️ UTANG KONTRAK U1 — `POST /dataspasial/sektor` belum ada di kontrak + * v0.1.0. `404`/`405` dari API otomatis menjadi pesan "data sementara tidak + * tersedia" lewat pemetaan `DataSpasialResult` (FR-005). + */ + public function storeSektor(StoreSektorRequest $request): RedirectResponse + { + $validated = $request->validated(); + + // ⚠️ Keunikan nama sektor diperiksa API (spec Q13) — bukan di sini. + // Validasi bentuk sudah dijaga `StoreSektorRequest`. + $result = $this->dataspasial->buatSektor([ + 'nama' => trim((string) $validated['nama']), + 'ikon' => $validated['ikon'] ?? null, + ]); + + if ($result->isUnavailable()) { + Log::warning('DataSpasial: pembuatan sektor gagal', [ + 'reason' => $result->reason?->value, + 'http_status' => $result->httpStatus, + ]); + + return back() + ->withInput($request->only('nama', 'ikon')) + ->with('error', $result->userMessage()); + } + + return redirect() + ->route('dataspasial.skema', array_filter([ + 'sektor' => $result->first()['attributes']['kode'] ?? null, + ])) + ->with('success', 'Sektor berhasil disimpan.'); + } + + /** + * Simpan draf skema (FR-029 … FR-033). + */ + public function storeDraf(StoreDrafSkemaRequest $request): RedirectResponse + { + $validated = $request->validated(); + + $body = $this->editor->composeDrafPayload($validated['isian'] ?? [], [ + 'sektor' => $validated['sektor'] ?? null, + 'nomor_versi' => $validated['nomor_versi'] ?? null, + 'catatan_perubahan' => $validated['catatan_perubahan'] ?? null, + ]); + + $result = $this->dataspasial->buatDrafSkema($body); + + if ($result->isUnavailable()) { + Log::warning('DataSpasial: penyimpanan draf gagal', [ + 'sektor' => $validated['sektor'] ?? null, + 'reason' => $result->reason?->value, + 'http_status' => $result->httpStatus, + ]); + + return back() + ->withInput() + ->with('error', $result->userMessage()); + } + + return redirect() + ->route('dataspasial.skema', array_filter([ + 'sektor' => $validated['sektor'] ?? null, + 'skema' => $result->first()['id'] ?? null, + ])) + ->with('success', 'Draf skema berhasil disimpan.'); + } + + /** + * Ubah sektor yang sudah ada (US7, FR-007d-4 … FR-007d-9). + * + * ⚠️ UTANG KONTRAK U5 — `PUT /dataspasial/sektor/{id}` belum ada di kontrak + * v0.1.0 maupun API hidup. `404`/`405` otomatis menjadi pesan "data + * sementara tidak tersedia" dan daftar sektor tidak berubah (FR-007d-7). + * + * ⚠️ **Kode sektor tidak pernah dikirim** — kode bersifat baca-saja + * (FR-007d-5). + */ + public function updateSektor(UpdateSektorRequest $request, int $sektor): RedirectResponse + { + $validated = $request->validated(); + + $result = $this->dataspasial->ubahSektor($sektor, [ + 'nama' => trim((string) $validated['nama']), + 'ikon' => $validated['ikon'] ?? null, + ]); + + if ($result->isUnavailable()) { + Log::warning('DataSpasial: perubahan sektor gagal', [ + 'sektor_id' => $sektor, + 'reason' => $result->reason?->value, + 'http_status' => $result->httpStatus, + ]); + + return back() + ->withInput($request->only('nama', 'ikon')) + ->with('error', $result->userMessage()); + } + + $kode = $result->first()['attributes']['kode'] ?? $request->query('sektor'); + + return redirect() + ->route('dataspasial.skema', array_filter(['sektor' => $kode])) + ->with('success', 'Sektor berhasil diperbarui.'); + } + + /** + * 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; + } + + /** + * @param array> $skemaSektor + * @return array|null + */ + private function skemaTerpilih(Request $request, array $skemaSektor): ?array + { + $id = (int) $request->query('skema', 0); + + if ($id > 0) { + foreach ($skemaSektor as $skema) { + if ($skema['id'] === $id) { + return $skema; + } + } + } + + // Tanpa pilihan eksplisit: utamakan draf berjalan, lalu versi terbaru. + foreach ($skemaSektor as $skema) { + if ($skema['status'] === StatusSkemaEditor::Draf) { + return $skema; + } + } + + return $skemaSektor[0] ?? 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 $result; + 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 000000000..d23f340ef --- /dev/null +++ b/app/Http/Requests/DataSpasial/StoreDrafSkemaRequest.php @@ -0,0 +1,200 @@ + + */ + public function rules(): array + { + return [ + 'sektor' => ['required', 'string', 'max:100'], + 'catatan_perubahan' => ['nullable', 'string', 'max:500'], + 'nomor_versi' => ['nullable', 'integer', 'min:1'], + + // FR-019: sebuah sektor tidak boleh memiliki nol isian. + 'isian' => ['required', 'array', 'min:1'], + + // FR-017/Q14: `kode` adalah nilai TURUNAN dari `label`, bukan + // ketikan admin. Aturan di bawah TIDAK lagi menilai "apa yang admin + // ketik" — ia memverifikasi INTEGRITAS nilai turunan itu, karena + // FR-018 tetap mewajibkan kode hasil bangkit memenuhi pola + // snake_case. Bila label tidak menghasilkan slug yang sah, kode + // turunan akan kosong atau diawali angka dan ditolak di sini. + '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'], + + // ⚠️ ASUMSI U2 (U3) — penanda tampilan & pemetaan belum ada pada + // kontrak v0.1.0. Field di sini memakai NAMA INTERNAL; pemetaan ke + // nama asumsi kontrak hanya terjadi di `SkemaEditorService` + // (research.md R12). + 'isian.*.popupPeta' => ['boolean'], + 'isian.*.sebagaiFilter' => ['boolean'], + 'isian.*.sebagaiIndikator' => ['boolean'], + // ⚠️ VERIFIKASI LANGSUNG: `sumber` adalah ENUM API + // (`openSID`/`dataPresisi`/`impor`), bukan kode sektor. + 'isian.*.sumber' => ['nullable', 'string', Rule::in(['openSID', 'dataPresisi', 'impor'])], + 'isian.*.rujukan' => ['nullable', 'string', 'max:255'], + ]; + } + + /** + * 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); + }); + } + + /** + * 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.required' => 'Sektor wajib dipilih.', + ]; + } + + /** + * @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/StoreSektorRequest.php b/app/Http/Requests/DataSpasial/StoreSektorRequest.php new file mode 100644 index 000000000..6f582c6cc --- /dev/null +++ b/app/Http/Requests/DataSpasial/StoreSektorRequest.php @@ -0,0 +1,61 @@ + + */ + public function rules(): array + { + return [ + 'nama' => ['required', 'string', 'max:100', 'regex:/^[^\s](.*[^\s])?$/'], + 'ikon' => ['nullable', 'string', 'max:100'], + ]; + } + + /** + * @return array + */ + public function messages(): array + { + return [ + 'nama.required' => 'Nama sektor wajib diisi.', + 'nama.regex' => 'Nama sektor tidak boleh diawali atau diakhiri spasi.', + 'nama.max' => 'Nama sektor maksimal 100 karakter.', + ]; + } + + /** + * @return array + */ + public function attributes(): array + { + return ['nama' => 'nama sektor']; + } +} diff --git a/app/Http/Requests/DataSpasial/TerbitSkemaRequest.php b/app/Http/Requests/DataSpasial/TerbitSkemaRequest.php new file mode 100644 index 000000000..989687ad6 --- /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/Http/Requests/DataSpasial/UpdateSektorRequest.php b/app/Http/Requests/DataSpasial/UpdateSektorRequest.php new file mode 100644 index 000000000..e5f8ca521 --- /dev/null +++ b/app/Http/Requests/DataSpasial/UpdateSektorRequest.php @@ -0,0 +1,59 @@ + + */ + public function rules(): array + { + return [ + 'nama' => ['required', 'string', 'max:100', 'regex:/^[^\s](.*[^\s])?$/'], + 'ikon' => ['nullable', 'string', 'max:100'], + ]; + } + + /** + * @return array + */ + public function messages(): array + { + return [ + 'nama.required' => 'Nama sektor wajib diisi.', + 'nama.regex' => 'Nama sektor tidak boleh diawali atau diakhiri spasi.', + 'nama.max' => 'Nama sektor maksimal 100 karakter.', + ]; + } + + /** + * @return array + */ + public function attributes(): array + { + return ['nama' => 'nama sektor']; + } +} diff --git a/app/Services/ArtikelService.php b/app/Services/ArtikelService.php index 0b0874f3a..e71dde3f9 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/CaptchaService.php b/app/Services/CaptchaService.php index bbc4cdf67..9a41c6327 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 6c21e796b..0dd7f3c23 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 index d50a9bbbe..abc55b542 100644 --- a/app/Services/DataSpasialApiService.php +++ b/app/Services/DataSpasialApiService.php @@ -421,20 +421,59 @@ public function listSektor(array $filters = []): DataSpasialResult } /** - * Daftar versi skema, opsional disaring per sektor. + * Daftar versi skema untuk SATU sektor. * - * @param array $filters + * ⚠️ 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); } @@ -547,4 +586,43 @@ public function imporNilai(array $body, ?int $schemaVersion = null): DataSpasial { return $this->request('POST', 'impor', [], $body, $schemaVersion); } + + /** + * Buat sektor baru. + * + * ⚠️ **UTANG KONTRAK U1** — endpoint `POST /dataspasial/sektor` **belum ada** + * pada `openapi-fondasi.yaml` v0.1.0 (hanya tersedia `GET /sektor` dan + * `GET /sektor/{id}`). Method ini memanggil endpoint yang belum + * ada dan harus diselaraskan setelah API-03 (#468) terbit. + * Bentuk asumsinya ada di `specs/004-pengelola-skema-editor/contracts/`. + * + * ⚠️ **Kode sektor TIDAK dikirim** — kode dibangkitkan oleh sisi API dan + * dijamin unik global (research.md R4, spec Q5). Bila API resmi justru + * mewajibkan kode, bentuk badan di sini perlu ditinjau. + * + * @param array $body Berisi `nama` dan opsional `ikon`. + */ + public function buatSektor(array $body, ?int $schemaVersion = null): DataSpasialResult + { + return $this->request('POST', 'sektor', [], $body, $schemaVersion); + } + + /** + * Ubah sektor yang sudah ada (FR-007d-4, spec Q10). + * + * ⚠️ **UTANG KONTRAK U5** — `PUT /dataspasial/sektor/{id}` **belum ada** + * pada `openapi-fondasi.yaml` v0.1.0 maupun di API hidup (`404`). Method ini + * memanggil endpoint yang belum ada dan harus diselaraskan setelah API-03 + * (#468) terbit. Bentuk asumsinya ada di + * `specs/004-pengelola-skema-editor/contracts/openapi-fragment.yaml`. + * + * ⚠️ **Kode sektor TIDAK dikirim dan tidak dapat diubah** — kode + * dibangkitkan server dan bersifat baca-saja (FR-007d-5). + * + * @param array $body Berisi `nama` dan opsional `ikon`. + */ + public function ubahSektor(int $id, array $body, ?int $schemaVersion = null): DataSpasialResult + { + return $this->request('PUT', 'sektor/'.$id, [], $body, $schemaVersion); + } } diff --git a/app/Services/IlluminatePaginatorAdapter.php b/app/Services/IlluminatePaginatorAdapter.php index 705ac6d38..ca399e45d 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 2d0bbda03..0c18b685c 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 c6f256bdf..728d90649 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 000000000..ce23caf38 --- /dev/null +++ b/app/Services/SkemaEditorService.php @@ -0,0 +1,431 @@ + + */ + 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) — dipakai untuk label versi/draf + // TANPA perlu memanggil endpoint skema lebih awal. + 'jumlahSkema' => (int) ($attributes['jumlah_versi_skema'] ?? 0), + 'jumlahTerbit' => (int) ($attributes['jumlah_versi_terbit'] ?? 0), + 'adaDraf' => false, + 'nomorVersiDraf' => null, + ]; + } + + /** + * Petakan daftar item sektor dan hitung berapa skema/draf tiap sektor (R1). + * + * @param array> $items + * @return array> + */ + public function petakanDaftarSektor(array $items, array $semuaSkema = []): array + { + return array_map(function (array $item) use ($semuaSkema) { + $sektor = $this->petakanSektor($item); + $skemaSektor = $this->groupSkemaPerSektor($semuaSkema)[$sektor['id']] ?? []; + + $draf = $this->cariSkema($skemaSektor, StatusSkemaEditor::Draf); + + // Bila counter dari API tidak tersedia, hitung dari daftar skema. + $jumlahSkema = $sektor['jumlahSkema'] ?: count($skemaSektor); + + return array_merge($sektor, [ + 'jumlahSkema' => $jumlahSkema, + 'adaDraf' => $draf !== null || ($jumlahSkema > 0 && $sektor['jumlahTerbit'] === 0), + 'nomorVersiDraf' => $draf['nomorVersi'] ?? null, + ]); + }, $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'] : []) ?? []; + + $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', + 'rujukan' => $pemetaan['rujukan'] ?? null, + '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); + } + + /** + * Kelompokkan daftar skema global per sektor agar panel skema tidak + * memanggil API sekali per sektor (R1). + * + * @param array> $items + * @return array>> + */ + public function groupSkemaPerSektor(array $items): array + { + $perSektor = []; + + foreach ($items as $item) { + $skema = $this->petakanSektorSkema($item); + $perSektor[$skema['sektorId']][] = $skema; + } + + foreach ($perSektor as &$daftar) { + usort($daftar, static fn (array $a, array $b): int => $b['nomorVersi'] <=> $a['nomorVersi']); + } + unset($daftar); + + return $perSektor; + } + + /** + * Petakan satu entri riwayat versi menjadi bentuk daftar skema (Q9). + * + * ⚠️ Bentuk `riwayat versi skema` **datar** — berbeda dari bentuk + * `skema data spasial` yang bersarang di `attributes.versi_skema`. + * + * @param array $item + * @return array + */ + public function petakanRiwayatVersi(array $item): array + { + $attributes = $this->attributes($item); + + $sektorId = (int) ($attributes['sektor_id'] ?? 0); + $id = (int) ($item['id'] ?? $attributes['id'] ?? 0); + + return [ + 'id' => $id, + 'sektorId' => $sektorId, + 'nomorVersi' => (int) ($attributes['nomor_versi'] ?? 0), + 'status' => (string) ($attributes['status'] ?? ''), + 'catatanPerubahan' => $attributes['catatan_perubahan'] ?? null, + 'diterbitkanPada' => $attributes['diterbitkan_pada'] ?? null, + 'diterbitkanOleh' => $attributes['diterbitkan_oleh'] ?? null, + 'jumlahIsian' => 0, + ]; + } + + /** + * 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), + ]); + } + + // ----------------------------------------------------------------------- + // 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 RESMI API (verifikasi langsung — lihat §6a.4 K4). + 'tampil_popup' => (bool) ($baris['popupPeta'] ?? false), + 'tampil_filter' => (bool) ($baris['sebagaiFilter'] ?? false), + 'tampil_indikator' => (bool) ($baris['sebagaiIndikator'] ?? false), + // ⚠️ ASUMSI U3 — pemetaan inline di badan draf. + 'pemetaan' => $this->composePemetaan($baris), + ]; + + return $payload; + }, $isian); + + return array_merge([ + 'catatan_perubahan' => $meta['catatan_perubahan'] ?? null, + 'nomor_versi' => $meta['nomor_versi'] ?? null, + 'isian' => $isianApi, + ], $meta['sektor'] !== null ? ['sektor' => $meta['sektor']] : []); + } + + /** + * @param array $baris + * @return array{sektor: string|null, rujukan: string|null}|null + */ + private function composePemetaan(array $baris): ?array + { + $sumber = $baris['sumber'] ?? null; + + if ($sumber === null || $sumber === '' || $sumber === 'manual') { + return null; + } + + // ⚠️ API menerima pemetaan sebagai ARRAY di dalam tiap isian (verifikasi + // langsung §6a.3) — bukan objek tunggal seperti asumsi lama. + return [[ + 'id' => 0, + 'sumber' => (string) $sumber, + 'rujukan_sumber' => $baris['rujukan'] ?? null, + 'aturan_hitung' => $baris['aturanHitung'] ?? null, + ]]; + } + + // ----------------------------------------------------------------------- + // 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 c66890906..58e375e17 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/database/factories/UserFactory.php b/database/factories/UserFactory.php index 2a9c4e3b9..b538a4bc0 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/public/js/dataspasial/schema-editor.js b/public/js/dataspasial/schema-editor.js new file mode 100644 index 000000000..d5244d74a --- /dev/null +++ b/public/js/dataspasial/schema-editor.js @@ -0,0 +1,946 @@ +/** + * 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 + */ +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 opsi = nilai('.input-opsi'); + 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() ?? '', + opsi: opsi && !opsi.disabled + ? Array.from(opsi.selectedOptions).map((o) => o.value).filter(Boolean) + : [], + 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', + // ⚠️ `dataset` memakai camelCase: `data-aturan-hitung` → + // `dataset.aturanHitung`. Akses `dataset?.aturan-hitung` + // akan diparse sebagai pengurangan (`aturan - hitung`) dan + // melempar ReferenceError setiap kali serialisasi berjalan. + // Belum ada sumber yang menulis atribut ini, jadi default null. + aturanHitung: sel?.dataset?.aturanHitung ?? null, + rujukan: sel?.dataset?.rujukan || 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 opsi = tr.querySelector('.input-opsi'); + const satuan = tr.querySelector('.input-satuan'); + const bolehUbah = tbody.dataset.bolehUbah === '1'; + + if (opsi) { + opsi.disabled = !bolehUbah || tipe !== TIPE_PILIHAN; + } + + 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); + tambah('rujukan', baris.rujukan || ''); + } + }); +} + +/** + * 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); + } + }); +} + +/** + * Konfirmasi "sudah ada draf berjalan" (FR-007g/FR-007h, spec Q6). + * + * @param {number} RedirectKe + * @returns {Promise} + */ +function konfirmasiDrafAda(RedirectKe) { + if (typeof window.Swal === 'undefined') { + if (window.confirm('Sektor ini sudah memiliki draf berjalan. Buka draf tersebut?')) { + window.location.href = RedirectKe; + } + + return Promise.resolve(false); + } + + return window.Swal.fire({ + title: 'Draf sudah ada', + text: 'Sektor ini sudah memiliki draf berjalan. Buka draf tersebut?', + icon: 'warning', + showCancelButton: true, + confirmButtonText: 'Buka draf', + cancelButtonText: 'Batal', + }).then((hasil) => { + if (hasil.isConfirmed) { + window.location.href = RedirectKe; + } + + return false; + }); +} + +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'); + const bolehUbah = tbody.dataset.bolehUbah === '1'; + const adaDrafBerjalan = Boolean(document.querySelector('[data-testid="item-skema"][data-status="draf"]')); + const sektorKode = akar.dataset.sektor || ''; + const skemaAktif = akar.dataset.skema || ''; + + let dirty = false; + + 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 window.confirm(pesan); + }; + + // ----------------------------------------------------------------------- + // Tambah isian (US1#13, US2) + // ----------------------------------------------------------------------- + document.getElementById('btn-tambah-isian')?.addEventListener('click', () => { + if (!template) { + 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', (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 (!window.confirm(pesan)) { + return; + } + + tr?.remove(); + perbaruiNomorUrutan(tbody); + mutasi(); + }); + + // ----------------------------------------------------------------------- + // Tambah sektor (US1#8) + // ----------------------------------------------------------------------- + const formSektor = document.getElementById('form-tambah-sektor'); + + document.getElementById('btn-tambah-sektor')?.addEventListener('click', () => { + formSektor?.classList.toggle('d-none'); + document.getElementById('input-nama-sektor')?.focus(); + }); + + document.getElementById('btn-batal-sektor')?.addEventListener('click', () => { + formSektor?.classList.add('d-none'); + }); + + // ----------------------------------------------------------------------- + // Ubah sektor (US7, FR-007d-4 … FR-007d-6) + // + // ⚠️ KODE SEKTOR TIDAK DIKIRIM — form hanya memuat `nama` + `ikon` + // (FR-007d-5). Daftar sektor tidak ikut dikirim karena keunikan nama + // diperiksa API (spec Q13). + // ----------------------------------------------------------------------- + const formUbahSektor = document.getElementById('form-ubah-sektor'); + + const tutupFormUbahSektor = () => { + formUbahSektor?.classList.add('d-none'); + }; + + document.querySelectorAll('.btn-ubah-sektor').forEach((tombol) => { + tombol.addEventListener('click', (event) => { + // Item sektor memakai `list-group-item-action` sehingga klik pada + // elemen anak ikut memicu navigasi — klik tombol harus dihentikan. + event.preventDefault(); + event.stopPropagation(); + + const item = tombol.closest('[data-testid="item-sektor"]'); + const template = formUbahSektor?.dataset.urlTemplate; + + if (!item || !template) { + return; + } + + const id = item.dataset.sektorId || ''; + + if (id === '') { + return; + } + + if (formUbahSektor) { + formUbahSektor.action = template.replace('__ID__', id); + formUbahSektor.classList.remove('d-none'); + + // Kode hanya ditampilkan sebagai informasi, bukan input. + const labelKode = formUbahSektor.querySelector('[data-testid="ubah-sektor-kode"]'); + if (labelKode) { + labelKode.textContent = item.dataset.sektorKode || ''; + } + + const inputNama = formUbahSektor.querySelector('#input-ubah-nama-sektor'); + if (inputNama) { + inputNama.value = item.dataset.sektorNama || ''; + } + + const inputIkon = formUbahSektor.querySelector('#input-ubah-ikon-sektor'); + if (inputIkon) { + inputIkon.value = item.dataset.sektorIkon || ''; + } + + inputNama?.focus(); + } + }); + }); + + document.getElementById('btn-batal-ubah-sektor')?.addEventListener('click', tutupFormUbahSektor); + + // Menutup form ubah bila admin membuka form tambah (hanya satu form terbuka). + document.getElementById('btn-tambah-sektor')?.addEventListener('click', tutupFormUbahSektor); + + // ----------------------------------------------------------------------- + // Tambah skema baru (US1#9, Q6/R6) + // ----------------------------------------------------------------------- + document.getElementById('btn-tambah-skema')?.addEventListener('click', async () => { + if (!sektorKode) { + return; + } + + // Draf sudah berjalan → konfirmasi, JANGAN buat draf kedua (FR-007g). + if (adaDrafBerjalan) { + await konfirmasiDrafAda(`${window.location.pathname}?sektor=${encodeURIComponent(sektorKode)}`); + + return; + } + + 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: sektorKode, isian: [] }), + }); + + if (respons.status === 422) { + window.alert('Sektor ini sudah memiliki draf berjalan.'); + + return; + } + + if (!respons.ok) { + window.alert('Data sementara tidak tersedia. Silakan coba lagi nanti.'); + + return; + } + + window.location.href = `${window.location.pathname}?sektor=${encodeURIComponent(sektorKode)}`; + } catch (error) { + window.alert('Data sementara tidak tersedia. Silakan coba lagi nanti.'); + } + }); + + // ----------------------------------------------------------------------- + // Simpan draf (US3) — pemicu konfirmasi yang sama (FR-007h) + // ----------------------------------------------------------------------- + document.getElementById('btn-simpan-draf')?.addEventListener('click', async () => { + if (!bolehUbah) { + return; + } + + // FR-007i: bila admin SEDANG menyunting draf itu sendiri, konfirmasi + // tidak boleh muncul. + const menyuntingDrafIni = Boolean(skemaAktif) + && document.querySelector(`[data-testid="item-skema"][data-skema-id="${skemaAktif}"][data-status="draf"]`); + + if (adaDrafBerjalan && !menyuntingDrafIni) { + await konfirmasiDrafAda(`${window.location.pathname}?sektor=${encodeURIComponent(sektorKode)}&skema=${skemaAktif}`); + + return; + } + + const isian = bacaDariDom(tbody); + + if (isian.length === 0) { + window.alert('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) { + window.alert(`Label "${labelBentrok[0]}" dipakai lebih dari satu isian pada versi ini.`); + + return; + } + + const kodeKosong = isian.find((b) => b.kode === ''); + + if (kodeKosong) { + window.alert(`Label "${kodeKosong.label}" tidak menghasilkan kode yang sah. Perbaiki labelnya.`); + + return; + } + + serialisasiKeForm(isian); + dirty = false; + perbaruiTombolTerbit(); + document.getElementById('form-draf')?.submit(); + }); + + // ----------------------------------------------------------------------- + // 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; + } + + 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', () => { + if (!formTerbit || !sektorKode) { + return; + } + + // FR-045 — pengaman kedua di sisi klien. + if (dirty) { + window.alert('Simpan draf dulu. Ada perubahan yang belum disimpan.'); + + return; + } + + const item = skemaAktif + ? document.querySelector(`[data-testid="item-skema"][data-skema-id="${skemaAktif}"]`) + : null; + const namaSektor = document.querySelector('[data-testid="item-sektor"][data-sektor-kode="' + sektorKode + '"]') + ?.dataset?.sektorNama || sektorKode; + const nomorVersi = item?.dataset?.nomorVersi || '?'; + + // FR-042 — konfirmasi menyebut sektor & nomor versi secara eksplisit. + const pesan = `Terbitkan versi ${nomorVersi} pada sektor "${namaSektor}"?\n\n` + + 'Setelah diterbitkan, versi ini menjadi immutable dan tidak dapat diubah. ' + + 'Desa yang sudah memakai versi lama tidak ikut berubah.'; + + if (!window.confirm(pesan)) { + return; + } + + const inputCatatan = formTerbit.querySelector('[name="catatan_perubahan"]'); + if (inputCatatan) { + inputCatatan.value = window.prompt('Catatan perubahan (opsional)', '') || ''; + } + + formTerbit.submit(); + }); + + // ----------------------------------------------------------------------- + // Pemetaan isian ke sumber = daftar sektor (US4, FR-022/FR-023/FR-024) + // ----------------------------------------------------------------------- + const modal = document.getElementById('modal-pilih-sumber'); + let barisDipilih = null; + + const tulisPemetaan = (tr, sumber, nama) => { + const sel = tr.querySelector('.sumber-nilai'); + + if (!sel) { + return; + } + + sel.dataset.sumber = sumber; + sel.dataset.rujukan = ''; + + 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 = '' + nama; + } else { + badge.className = 'badge badge-secondary'; + badge.textContent = 'Manual'; + } + }; + + tbody.addEventListener('click', (event) => { + const tombol = event.target.closest('.btn-pilih-sumber-baris'); + + if (!tombol || !modal) { + return; + } + + barisDipilih = tombol.closest('tr'); + + const sel = barisDipilih.querySelector('.sumber-nilai'); + const label = document.getElementById('sumber-kode-isian'); + + if (label) { + label.textContent = barisDipilih.querySelector('.input-label')?.value || '(tanpa label)'; + } + + const sudahAda = sel?.dataset.sumber && sel.dataset.sumber !== 'manual'; + const tombolLepas = document.getElementById('btn-lepas-pemetaan'); + + if (tombolLepas) { + tombolLepas.classList.toggle('d-none', !sudahAda); + } + + const cari = document.getElementById('cari-sumber'); + + if (cari) { + cari.value = ''; + } + + document.querySelectorAll('#daftar-sumber .item-sumber').forEach((item) => { + item.style.display = ''; + }); + + // eslint-disable-next-line no-undef + window.jQuery?.(modal).modal('show'); + }); + + // Pencarian sederhana pada daftar sektor. + document.getElementById('cari-sumber')?.addEventListener('input', (event) => { + const kunci = event.target.value.toLowerCase(); + + document.querySelectorAll('#daftar-sumber .item-sumber').forEach((item) => { + const nama = (item.dataset.sektor || '').toLowerCase(); + const kode = (item.dataset.kode || '').toLowerCase(); + + item.style.display = (nama.includes(kunci) || kode.includes(kunci)) ? '' : 'none'; + }); + }); + + document.querySelectorAll('#daftar-sumber .btn-pilih-sumber')?.forEach((tombol) => { + tombol.addEventListener('click', () => { + if (!barisDipilih) { + return; + } + + tulisPemetaan(barisDipilih, tombol.dataset.kode, tombol.dataset.nama); + mutasi(); + + // eslint-disable-next-line no-undef + window.jQuery?.(modal).modal('hide'); + }); + }); + + document.getElementById('btn-lepas-pemetaan')?.addEventListener('click', () => { + if (!barisDipilih) { + return; + } + + tulisPemetaan(barisDipilih, 'manual', null); + mutasi(); + + // eslint-disable-next-line no-undef + window.jQuery?.(modal).modal('hide'); + }); + + // ----------------------------------------------------------------------- + // 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 000000000..2f37d4d46 --- /dev/null +++ b/resources/views/dataspasial/skema/partials/form-isian.blade.php @@ -0,0 +1,137 @@ +{{-- + 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 opsi hanya aktif bila tipe `pilihan`; kolom satuan hanya relevan bila + tipe `angka` (FR-010, `TipeIsian::memakaiPilihan()` / `memakaiSatuan()`). +--}} +@php + $tipe = \App\Enums\TipeIsian::hasValue($baris['tipe']) + ? \App\Enums\TipeIsian::fromValue($baris['tipe']) + : \App\Enums\TipeIsian::Teks(); + $memakaiPilihan = $tipe->memakaiPilihan(); + $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 + + + + + + + + + + + + + @error("isian.{$index}.opsi") +
{{ $message }}
+ @enderror + + + {{-- + 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 }} + @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 000000000..384698c75 --- /dev/null +++ b/resources/views/dataspasial/skema/partials/panel-sektor.blade.php @@ -0,0 +1,177 @@ +{{-- + 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). --}} + +
+ +{{-- + Formulir penambahan sektor. + + ⚠️ HANYA memuat kolom NAMA (spec Q5). Tidak ada kolom kode: kode dibangkitkan + API. ⚠️ Keunikan nama diperiksa API (spec Q13), bukan di OpenKab. +--}} +
+ @csrf + +
+
+

Sektor Baru

+
+
+
+ + + @error('nama') +
{{ $message }}
+ @enderror +
+ +
+ + +
+ +
+ + +
+
+
+
+ +{{-- + Formulir ubah sektor (US7, FR-007d-4 … FR-007d-6). + + ⚠️ **KODE SEKTOR TIDAK ADA DI SINI** — kode dibangkitkan server dan + bersifat baca-saja (FR-007d-5). Kode hanya ditampilkan sebagai informasi. + Badannya memuat `nama` + `ikon` sesuai `PUT /dataspasial/sektor/{id}`. + + ⚠️ Tidak ada `nama_sektor_ada` lagi — keunikan nama adalah tanggung jawab API + (spec Q13), sehingga OpenKab tidak perlu mengirim daftar sektor. +--}} +
+ @csrf + @method('PUT') + +
+
+

Ubah Sektor

+
+
+
+ +

+ + Kode tidak dapat diubah. +

+
+ +
+ + + @error('nama') +
{{ $message }}
+ @enderror +
+ +
+ + +
+ +
+ + +
+
+
+
\ No newline at end of file diff --git a/resources/views/dataspasial/skema/partials/panel-skema.blade.php b/resources/views/dataspasial/skema/partials/panel-skema.blade.php new file mode 100644 index 000000000..5f6b31614 --- /dev/null +++ b/resources/views/dataspasial/skema/partials/panel-skema.blade.php @@ -0,0 +1,78 @@ +{{-- + Panel kiri (bagian bawah): daftar skema untuk sektor yang dipilih. + + ⚠️ FR-007a — daftar skema HANYA dirender bila admin SUDAH memilih sektor. + Sebelum itu, panel menampilkan penjelasan, bukan daftar kosong yang membingungkan. +--}} +
+
+

+ + Skema + @if ($sektorTerpilih) + · {{ $sektorTerpilih['nama'] }} + @endif +

+
+ +
+ @if ($sektorTerpilih === null) + {{-- Belum ada sektor dipilih — daftar skema belum boleh tampil (FR-007a). --}} +

+ Pilih salah satu sektor di atas untuk melihat daftar skema versi dan drafnya. +

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

+ Sektor ini belum memiliki skema. Buat draf pertama untuk mulai menyusun isian. +

+ @else + + @endif +
+ + @if ($sektorTerpilih !== null) + {{-- + Tombol "+ Tambah Skema Baru" — DI BAWAH daftar skema (FR-007c). + + Sifatnya `type="button"`: sekali diklik, JS memeriksa apakah draf sudah + berjalan. Bila ada, tampil konfirmasi "Buka draf / Batal" (FR-007g/Q6); + bila belum, POST draf kosong ke `POST /dataspasial/skema`. + --}} + + @endif +
\ No newline at end of file diff --git a/resources/views/dataspasial/skema/partials/pilih-sumber.blade.php b/resources/views/dataspasial/skema/partials/pilih-sumber.blade.php new file mode 100644 index 000000000..63d540bfc --- /dev/null +++ b/resources/views/dataspasial/skema/partials/pilih-sumber.blade.php @@ -0,0 +1,69 @@ +{{-- + Pemilih sumber nilai untuk pemetaan isian (FR-022/FR-023). + + ⚠️ VERIFIKASI LANGSUNG KE API (§6a.5): `pemetaan[].sumber` adalah **ENUM** + `openSID` / `dataPresisi` / `impor` — BUKAN kode sektor. Keputusan Q2 ("sumber + = daftar sektor") TIDAK sesuai implementasi dan dicabut; daftar di bawah + memakai enum resmi tersebut. + + Isian yang sudah terpetakan ditampilkan sebagai badge pada tabel isian, + bukan sebagai select — melepaskannya dilakukan lewat tombol di sini. +--}} + \ No newline at end of file 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 000000000..7e71f3303 --- /dev/null +++ b/resources/views/dataspasial/skema/partials/pratinjau.blade.php @@ -0,0 +1,51 @@ +{{-- + 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'] }}
+ @if ($skemaTerpilih) + + Versi {{ $skemaTerpilih['nomorVersi'] }} + @if ($skemaTerpilih['status'] === 'terbit') + Terbit + @else + Draf + @endif + + @endif +
+ +
+ + {{-- 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 000000000..29d984caf --- /dev/null +++ b/resources/views/dataspasial/skema/partials/tabel-isian.blade.php @@ -0,0 +1,182 @@ +{{-- + 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, pilihan, popup peta, filter, + indikator, dan sumber nilai (manual atau pemetaan). + + 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['nama'] }} + @endif +

+ +
+ {{-- 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. + --}} + +
+
+ + @if ($skemaTerpilih !== null && $skemaTerpilih['status'] === '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 + +
 #LabelTipeWajibSatuanPilihanSumber nilaiPetaFilterIndikator 
+
+
+ + {{-- + Template baris kosong untuk "Tambah Isian Baru" (US1#13). + ⚠️ HANYA dirender saat skema dapat disunting. Pada skema `terbit` template + ini tidak boleh ada karena baris kosong-nya memuat tombol interaktif + (saklar & pemilih sumber) yang tidak boleh aktif (FR-008a). + --}} + @if ($bolehUbah) + + @endif + + {{-- Pemilih sumber nilai (FR-022): sumber = daftar sektor DataSpasial. --}} + @if ($bolehUbah) + @include('dataspasial.skema.partials.pilih-sumber') + @endif + + {{-- 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/routes/web.php b/routes/web.php index 67e3f1424..68531bb66 100644 --- a/routes/web.php +++ b/routes/web.php @@ -482,6 +482,28 @@ ->middleware('permission:dataspasial-skema-read') ->name('dataspasial.skema'); + // KAB-02 — endpoint tulis editor skema. Otorisasi ditegakkan server-side + // (FR-001), bukan sekadar menyembunyikan tombol di view. + Route::post('skema/sektor', [SkemaDataSpasialController::class, 'storeSektor']) + ->middleware('permission:dataspasial-skema-write') + ->name('dataspasial.skema.sektor.store'); + + // US7 — ubah sektor. ⚠️ UTANG KONTRAK U5: `PUT /dataspasial/sektor/{id}` + // belum ada di kontrak v0.1.0 maupun API hidup (FR-007d-4). + Route::put('skema/sektor/{sektor}', [SkemaDataSpasialController::class, 'updateSektor']) + ->middleware('permission:dataspasial-skema-write') + ->name('dataspasial.skema.sektor.update'); + + Route::post('skema/draf', [SkemaDataSpasialController::class, 'storeDraf']) + ->middleware('permission:dataspasial-skema-write') + ->name('dataspasial.skema.draf.store'); + + // US8 — terbitkan draf. ⚠️ UTANG KONTRAK U6: `POST /skema/{sektor}/terbit` + // belum ada di kontrak v0.1.0 maupun API hidup (FR-041). + 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'); diff --git a/tests/Browser/SmokeSkemaEditorTest.php b/tests/Browser/SmokeSkemaEditorTest.php new file mode 100644 index 000000000..e98ceeb80 --- /dev/null +++ b/tests/Browser/SmokeSkemaEditorTest.php @@ -0,0 +1,123 @@ +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/'); +}); \ No newline at end of file diff --git a/tests/Feature/DataSpasial/DataSpasialApiServiceTest.php b/tests/Feature/DataSpasial/DataSpasialApiServiceTest.php index 7007a7acf..21e222478 100644 --- a/tests/Feature/DataSpasial/DataSpasialApiServiceTest.php +++ b/tests/Feature/DataSpasial/DataSpasialApiServiceTest.php @@ -472,3 +472,91 @@ function sektorItem(string $id = '1', string $kode = 'pendidikan'): array ->and($service->cacheKey('/sektor', ['a' => 1, 'b' => 2])) ->toBe($service->cacheKey('/sektor', ['b' => 2, 'a' => 1])); }); + +// --------------------------------------------------------------------------- +// T006 — buatSektor() (U1: endpoint belum ada di kontrak v0.1.0) +// Bentuk respons disalin dari contracts/skema-editor-api.md §3.3 +// --------------------------------------------------------------------------- + +it('mengirim POST ke /sektor hanya dengan nama dan ikon tanpa kode', function () { + Http::fake(['*' => Http::response([ + 'success' => true, + 'message' => 'Sektor berhasil dibuat.', + 'data' => [ + 'type' => 'ds_sektor', + 'id' => '7', + 'attributes' => [ + 'id' => 7, + 'kode' => 'kesehatan', + 'nama' => 'Kesehatan', + 'ikon' => 'heart-pulse', + 'status' => 'aktif', + ], + ], + ], 201)]); + + $result = service()->buatSektor(['nama' => 'Kesehatan', 'ikon' => 'heart-pulse']); + + expect($result->isData())->toBeTrue() + ->and($result->httpStatus)->toBe(201) + ->and($result->data[0]['attributes']['kode'])->toBe('kesehatan'); + + Http::assertSent(function (Request $request) { + return $request->method() === 'POST' + && str_contains($request->url(), '/api/v1/dataspasial/sektor') + && $request->data() === ['nama' => 'Kesehatan', 'ikon' => 'heart-pulse']; + }); +}); + +it('tidak mengirim kode sektor karena kode dibangkitkan server (R4/Q5)', function () { + Http::fake(['*' => Http::response([ + 'success' => true, + 'data' => ['type' => 'ds_sektor', 'id' => '7', 'attributes' => ['kode' => 'kesehatan', 'nama' => 'Kesehatan']], + ], 201)]); + + service()->buatSektor(['nama' => 'Kesehatan']); + + Http::assertSent(function (Request $request) { + return ! array_key_exists('kode', $request->data()); + }); +}); + +it('memetakan 404 dan 405 dari /sektor sebagai client_error tanpa melempar exception', function (int $status) { + Http::fake(['*' => Http::response(['message' => 'Not Found'], $status)]); + + $result = service()->buatSektor(['nama' => 'Kesehatan']); + + expect($result->isUnavailable())->toBeTrue() + ->and($result->reason->value)->toBe('client_error') + ->and($result->httpStatus)->toBe($status) + ->and($result->userMessage())->toBe('Data sementara tidak tersedia. Silakan coba lagi nanti.'); +})->with([404, 405]); + +it('memetakan 409 nama sektor bentrok sebagai client_error', function () { + Http::fake(['*' => Http::response([ + 'success' => false, + 'message' => 'Validasi gagal.', + 'error_code' => 'ValidationException', + 'errors' => ['nama' => ['nama sudah dipakai sektor lain.']], + ], 409)]); + + $result = service()->buatSektor(['nama' => 'Pendidikan']); + + expect($result->isUnavailable())->toBeTrue() + ->and($result->reason->value)->toBe('client_error') + ->and($result->httpStatus)->toBe(409); +}); + +it('membuat cache baca tidak berlaku setelah pembuatan sektor', function () { + Http::fake([ + '*' => Http::response(successPayload([sektorItem()]), 200), + ]); + + service()->listSektor(); + Http::assertSentCount(1); + + service()->buatSektor(['nama' => 'Kesehatan']); + + service()->listSektor(); + Http::assertSentCount(3); +}); diff --git a/tests/Feature/DataSpasial/DataSpasialMenuAccessTest.php b/tests/Feature/DataSpasial/DataSpasialMenuAccessTest.php index 6793f3f2d..fd35ccf2e 100644 --- a/tests/Feature/DataSpasial/DataSpasialMenuAccessTest.php +++ b/tests/Feature/DataSpasial/DataSpasialMenuAccessTest.php @@ -144,6 +144,99 @@ function fakeApiSuccess(): void $this->get(route('dataspasial.skema'))->assertForbidden(); }); +// --------------------------------------------------------------------------- +// KAB-02 (T020) — Endpoint Tulis editor skema (FR-001) +// Izin tulis TIDAK menambah负担 pada peran lain: Operator Impor & Analis OPD +// tetap ditolak meski sudah bisa POST. +// --------------------------------------------------------------------------- + +it('menolak Operator Impor dan Analis OPD menyimpan draf skema', function (array $izin) { + actingAsWithTeam('penulis ditolak', $izin); + + $this->post(route('dataspasial.skema.draf.store'), [ + 'sektor' => 'pendidikan', + 'isian' => [[ + 'kode' => 'jumlah_siswa', + 'label' => 'Jumlah Siswa', + 'tipe' => 'angka', + 'urutan' => 1, + ]], + ])->assertForbidden(); +})->with([ + 'operator impor' => [['dataspasial-impor-read', 'dataspasial-impor-write']], + 'analis opd' => [['dataspasial-peta-read']], + 'tanpa izin data' => [[]], +]); + +it('menolak Operator Impor dan Analis OPD menambah sektor baru', function (array $izin) { + actingAsWithTeam('penambah sektor ditolak', $izin); + + $this->post(route('dataspasial.skema.sektor.store'), ['nama' => 'Kesehatan']) + ->assertForbidden(); +})->with([ + 'operator impor' => [['dataspasial-impor-read', 'dataspasial-impor-write']], + 'analis opd' => [['dataspasial-peta-read']], + 'tanpa izin data' => [[]], +]); + +// T075 (US7) — ubah sektor memakai izin tulis yang sama. +it('menolak Operator Impor dan Analis OPD mengubah sektor yang sudah ada', function (array $izin) { + actingAsWithTeam('pengubah sektor ditolak', $izin); + + $this->put(route('dataspasial.skema.sektor.update', ['sektor' => 101]), ['nama' => 'Pendidikan Dasar']) + ->assertForbidden(); +})->with([ + 'operator impor' => [['dataspasial-impor-read', 'dataspasial-impor-write']], + 'analis opd' => [['dataspasial-peta-read']], + 'tanpa izin data' => [[]], +]); + +// T082 (US8) — terbitkan memakai izin tulis yang sama. +it('menolak Operator Impor dan Analis OPD menerbitkan draf skema', function (array $izin) { + actingAsWithTeam('penerbit ditolak', $izin); + + $this->post(route('dataspasial.skema.terbit'), [ + 'sektor' => 'pendidikan', + 'skema' => 901, + ])->assertForbidden(); +})->with([ + 'operator impor' => [['dataspasial-impor-read', 'dataspasial-impor-write']], + 'analis opd' => [['dataspasial-peta-read']], + 'tanpa izin data' => [[]], +]); + +it('menolak peran tanpa izin Skema membuka halaman editor', function (array $izin) { + actingAsWithTeam('pembaca ditolak', $izin); + + $this->get(route('dataspasial.skema'))->assertForbidden(); +})->with([ + 'operator impor' => [['dataspasial-impor-read', 'dataspasial-impor-write']], + 'analis opd' => [['dataspasial-peta-read']], + 'tanpa izin data' => [[]], +]); + +it('membolehkan Admin Skema menyimpan draf dan menambah sektor', function () { + Http::fake([ + '*/api/v1/config/*' => Http::response(['data' => []], 200), + '*' => Http::response(['data' => ['type' => 'ds_skema_versi', 'id' => '9', 'attributes' => []]], 200), + ]); + + actingAsWithTeam('admin skema tulis', permissionNames('skema', ['read', 'write', 'edit', 'delete'])); + + $this->post(route('dataspasial.skema.draf.store'), [ + 'sektor' => 'pendidikan', + 'isian' => [[ + 'kode' => 'jumlah_siswa', + 'label' => 'Jumlah Siswa', + 'tipe' => 'angka', + 'urutan' => 1, + ]], + ])->assertSessionHasNoErrors(); + + $this->post(route('dataspasial.skema.sektor.store'), ['nama' => 'Kesehatan']) + ->assertSessionHasNoErrors(); +}); + // --------------------------------------------------------------------------- // T027 — Menu tidak tampil bagi peran tanpa izin // --------------------------------------------------------------------------- diff --git a/tests/Feature/DataSpasial/Fixtures.php b/tests/Feature/DataSpasial/Fixtures.php new file mode 100644 index 000000000..df0b90ba8 --- /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 000000000..720560af8 --- /dev/null +++ b/tests/Feature/DataSpasial/SkemaEditorDrafTest.php @@ -0,0 +1,320 @@ + '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 us3'], ['menu' => []]); + setPermissionsTeamId($team->id); + + $role = Role::firstOrCreate(['name' => 'admin skema us3', '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 US3'])->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 isianDraf(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); +} + +/** + * @param array> $isian + * @return array + */ +function draf(array $isian, array $tambah = []): array +{ + return array_merge(['sektor' => 'pendidikan', 'catatan_perubahan' => 'Draf uji.'], $tambah, ['isian' => $isian]); +} + +// --------------------------------------------------------------------------- +// US3#1 — simpan berhasil +// --------------------------------------------------------------------------- + +it('menyimpan draf dan memberi umpan balik sukses', function () { + Http::fake([ + '*/api/v1/dataspasial/skema' => Http::response([ + 'data' => ['type' => 'skema data spasial', 'id' => '9', 'attributes' => [ + 'versi_skema' => ['id' => 9, 'sektor_id' => 1, 'nomor_versi' => 4, 'status' => 'draf'], + 'isian' => [], + ]], + ], 201), + '*' => Http::response(['data' => []], 200), + ]); + + $this->post($this->urlDraf, draf([isianDraf()])) + ->assertRedirect() + ->assertSessionHas('success'); + + Http::assertSent(function ($request) { + return $request->method() === 'POST' + && str_contains($request->url(), '/api/v1/dataspasial/skema') + && $request->data()['sektor'] === 'pendidikan'; + }); +}); + +it('mengalihkan ke sektor dan skema hasil penyimpanan', function () { + Http::fake([ + '*/api/v1/dataspasial/skema' => Http::response([ + 'data' => ['type' => 'skema data spasial', 'id' => '9', 'attributes' => [ + 'versi_skema' => ['id' => 9, 'sektor_id' => 1, 'nomor_versi' => 4, 'status' => 'draf'], + 'isian' => [], + ]], + ], 201), + '*' => Http::response(['data' => []], 200), + ]); + + $this->post($this->urlDraf, draf([isianDraf()])) + ->assertRedirect(route('dataspasial.skema', ['sektor' => 'pendidikan', 'skema' => 9])); +}); + +it('menampilkan label draf pada daftar sektor setelah simpan tanpa muat ulang manual', function () { + fakeEndpointEditor( + [fxSektor('1', 'pendidikan', 'Pendidikan', 2, 0)], + [fxRiwayatVersi('9', 1, 4, 'draf')] + ); + + $this->get(route('dataspasial.skema', ['sektor' => 'pendidikan'])) + ->assertOk() + ->assertSee('Draf berjalan') + ->assertSee('Versi 4'); +}); + +// --------------------------------------------------------------------------- +// US3#2 — draf dapat dibuka kembali (FR-030) +// --------------------------------------------------------------------------- + +it('mengembalikan draf tersimpan lengkap dengan urutan pilihan dan saklar', function () { + // ⚠️ Sengaja TIDAK terurut agar urutan diurutkan oleh service (FR-012). + fakeEndpointEditor( + [fxSektor('1', 'pendidikan', 'Pendidikan')], + [fxRiwayatVersi('9', 1, 4, 'draf')], + fxSkemaAktif('9', 1, 'pendidikan', 'Pendidikan', 4, 'draf', [ + fxIsian('jenis_sekolah', ['id' => 32, 'tipe' => 'pilihan', 'opsi' => ['Negeri', 'Swasta'], + 'urutan' => 2, 'tampil_filter' => true]), + fxIsian('jumlah_siswa', ['id' => 31, 'tipe' => 'angka', 'wajib' => true, 'satuan' => 'orang', + 'urutan' => 1, 'tampil_popup' => true, 'tampil_indikator' => true, + 'pemetaan' => [['id' => 3951, 'sumber' => 'openSID', + 'rujukan_sumber' => 'data_presisi_pendidikan.*', 'aturan_hitung' => null]]]), + ]) + ); + + $content = $this->get(route('dataspasial.skema', ['sektor' => 'pendidikan', 'skema' => 9])) + ->assertOk() + ->getContent(); + + // Urutan kembali sesuai yang tersimpan, bukan urutan balasan API (FR-030). + expect($content) + ->toContain('jumlah_siswa') + ->toContain('jenis_sekolah') + ->toContain('Negeri') + ->toContain('Swasta') + // Saklar tampilan kembali seperti tersimpan (FR-028, nama resmi K4). + ->toContain('class="input-popup-peta" checked') + ->toContain('class="input-filter" checked') + ->toContain('class="input-indikator" checked') + // Pemetaan ikut kembali dan tampil terkunci (FR-030/FR-015). + ->toContain('openSID') + ->toContain('badge-terpetakan'); +}); + +// --------------------------------------------------------------------------- +// US3#3 — sumber mati saat menyimpan (FR-032) +// --------------------------------------------------------------------------- + +it('menampilkan pesan data sementara tidak tersedia tanpa kehilangan isi saat API mati', function () { + Http::fake([ + '*/api/v1/dataspasial/skema' => fn () => throw new ConnectionException('cURL error 28: Operation timed out'), + '*' => Http::response(['data' => []], 200), + ]); + + $response = $this->post($this->urlDraf, draf([isianDraf()])); + + $response->assertRedirect(); + expect(session('error'))->toBe('Data sementara tidak tersedia. Silakan coba lagi nanti.'); + + // Isi form dikembalikan (old input) sehingga admin dapat mencoba ulang. + $response->assertSessionHasInput('isian.0.kode', 'jumlah_siswa') + ->assertSessionHasInput('sektor', 'pendidikan'); +}); + +it('menampilkan pesan yang sama saat modul belum terpasang', function () { + Http::fake([ + '*/api/v1/dataspasial/skema' => Http::response([ + 'success' => false, + 'message' => 'Modul DataSpasial belum terpasang atau versinya tidak cocok', + ], 503), + '*' => Http::response(['data' => []], 200), + ]); + + $this->post($this->urlDraf, draf([isianDraf()]))->assertSessionHas('error'); + + expect(session('error'))->toBe('Data sementara tidak tersedia. Silakan coba lagi nanti.'); +}); + +it('mencatat kegagalan ke log tanpa membocorkan pesan teknis ke pengguna', function () { + Log::spy(); + + Http::fake([ + '*/api/v1/dataspasial/skema' => Http::response([ + 'success' => false, + 'message' => 'Modul DataSpasial belum terpasang atau versinya tidak cocok', + ], 503), + '*' => Http::response(['data' => []], 200), + ]); + + $this->post($this->urlDraf, draf([isianDraf()])); + + expect(session('error'))->not->toContain('belum terpasang'); + + Log::shouldHaveReceived('warning') + ->withArgs(fn (string $m, array $c = []) => str_contains($m, 'DataSpasial') + && ($c['reason'] ?? null) === 'module_not_installed'); +}); + +// --------------------------------------------------------------------------- +// US3#4 — API menolak draf (FR-021) +// --------------------------------------------------------------------------- + +it('menampilkan penjelasan yang dapat ditindaklanjuti saat API menolak draf', function () { + Log::spy(); + + Http::fake([ + '*/api/v1/dataspasial/skema' => Http::response([ + 'success' => false, + 'message' => 'Validasi gagal.', + 'error_code' => 'ValidationException', + 'errors' => ['isian.0.kode' => ['Kode isian tidak dikenal pada versi skema ini.']], + ], 422), + '*' => Http::response(['data' => []], 200), + ]); + + $this->post($this->urlDraf, draf([isianDraf()]))->assertSessionHas('error'); + + // Pesan teknis API tidak boleh sampai ke pengguna (FR-016). + expect(session('error'))->not->toContain('ValidationException'); + + Log::shouldHaveReceived('warning'); +}); + +it('mempertahankan isi draf ketika API menolak dengan 422', function () { + Http::fake([ + '*/api/v1/dataspasial/skema' => Http::response(['message' => 'ditolak'], 422), + '*' => Http::response(['data' => []], 200), + ]); + + $this->post($this->urlDraf, draf([isianDraf(['kode' => 'jumlah_siswa'])])) + ->assertSessionHasInput('isian.0.kode', 'jumlah_siswa'); +}); + +it('menangani konflik draf berjalan tanpa membuat draf kedua', function () { + Http::fake([ + '*/api/v1/dataspasial/skema' => Http::response([ + 'success' => false, + 'message' => 'Sektor ini sudah memiliki draf berjalan.', + 'error_code' => 'NoActiveDraft', + ], 409), + '*' => Http::response(['data' => []], 200), + ]); + + $this->post($this->urlDraf, draf([isianDraf()]))->assertSessionHas('error'); + + expect(session('error'))->not->toContain('NoActiveDraft'); +}); + +// --------------------------------------------------------------------------- +// Cache — penyimpanan wajib membuat cache baca tidak berlaku (SC-008) +// --------------------------------------------------------------------------- + +it('membuat cache baca tidak berlaku setelah menyimpan draf', function () { + $sektorPayload = [ + '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]], + ]; + + // ⚠️ `Http::fake()` dipanggil SATU KALI saja — memanggilnya ulang di tengah + // test akan mereset pencatat request sehingga `assertSentCount()` tidak + // lagi dapat menghitung. + Http::fake([ + '*/api/v1/config/*' => Http::response(['data' => []], 200), + '*/api/v1/dataspasial/sektor*' => Http::response($sektorPayload, 200), + '*' => Http::response($sektorPayload, 200), + ]); + + $service = app(DataSpasialApiService::class); + + $service->listSektor(); + Http::assertSentCount(1); + + $this->post($this->urlDraf, draf([isianDraf()])); + Http::assertSentCount(2); + + $service->listSektor(); + + // Cache lama tidak boleh dipakai lagi → request ketiga benar-benar dikirim. + Http::assertSentCount(3); +}); diff --git a/tests/Feature/DataSpasial/SkemaEditorEditSektorTest.php b/tests/Feature/DataSpasial/SkemaEditorEditSektorTest.php new file mode 100644 index 000000000..fece6e402 --- /dev/null +++ b/tests/Feature/DataSpasial/SkemaEditorEditSektorTest.php @@ -0,0 +1,250 @@ + '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 us7', [ + 'dataspasial-skema-read', + 'dataspasial-skema-write', + 'dataspasial-skema-edit', + 'dataspasial-skema-delete', + ]); + + test()->actingAs($this->adminSkema, 'web'); +}); + +/** + * Pasang stub HTTP untuk halaman editor **beserta** respons `PUT sektor/{id}`. + * + * ⚠️ Dua hal yang menyesatkan pada `Http::fake()`: + * 1. Pola **FIFO** — pola yang terdaftar lebih dulu selalu menang, sehingga + * wildcard `*` menutupi semua stub setelahnya. + * 2. Awalan `PUT `/`POST ` BUKAN filter method — awalan itu ikut diperlakukan + * sebagai glob dan membuat URL tidak akan pernah cocok. Karena itu + * pemisahan GET/PUT dilakukan di dalam closure. + * + * @param array> $sektor + */ +function fakeEditor(?int $putStatus = null, ?array $sektor = null): void +{ + $sektor ??= [fxSektor('101', 'pendidikan', 'Pendidikan')]; + + Http::fake([ + '*/api/v1/config/*' => Http::response(fakeConfigEndpoint(), 200), + '*/api/v1/dataspasial/sektor*' => function (Request $request) use ($putStatus, $sektor) { + if ($request->method() === 'PUT') { + if ($putStatus === 422) { + return Http::response([ + 'message' => 'Nama sektor sudah digunakan.', + 'errors' => ['nama' => ['Nama sektor sudah digunakan.']], + ], 422); + } + + return $putStatus === null + ? Http::response(responsUbahSektor(), 200) + : Http::response(['message' => 'Gagal'], $putStatus); + } + + return Http::response(fakeKoleksi($sektor), 200); + }, + '*/api/v1/dataspasial/skema/riwayat*' => Http::response( + fakeKoleksi([fxRiwayatVersi('901', 101, 1, 'draf')]), 200 + ), + '*/api/v1/dataspasial/skema?*' => Http::response( + fxSkemaAktif('101', 101, 'pendidikan', 'Pendidikan', 1, 'draf', [fxIsian('jumlah_siswa')]), 200 + ), + '*' => Http::response(['data' => []], 200), + ]); +} + +/** + * Respons berhasil `PUT /dataspasial/sektor/{id}`. + * + * @return array + */ +function responsUbahSektor(): array +{ + return ['data' => ['id' => 101, 'type' => 'sektor', 'attributes' => [ + 'kode' => 'pendidikan', 'nama' => 'Pendidikan Dasar', 'ikon' => 'school', + ]]]; +} + +// ----------------------------------------------------------------------- +// US7 #1 — form edit: kode baca-saja (FR-007d-5) +// ----------------------------------------------------------------------- + +it('menampilkan form edit dengan nama dan ikon terisi serta kode baca-saja', function () { + fakeEditor(); + + $response = $this->get(route('dataspasial.skema', ['sektor' => 'pendidikan'])); + + $response->assertOk(); + $response->assertSee('Pendidikan'); + $response->assertSee('form-ubah-sektor', false); + + // ⚠️ Nilai form diisi JS dari `data-sektor-*` pada item daftar, bukan + // dirender server (FR-007d-6). Yang diuji adalah sumber datanya. + $response->assertSee('data-sektor-nama="Pendidikan"', false); + $response->assertSee('id="input-ubah-nama-sektor"', false); + // Kode tampil sebagai informasi, bukan input yang bisa disunting. + $response->assertDontSee('name="kode"', false); + // ⚠️ Daftar sektor tidak lagi dikirim (keunikan = tugas API, spec Q13). + $response->assertDontSee('nama_sektor_ada', false); +}); + +// ----------------------------------------------------------------------- +// US7 #3/#4 — validasi (FR-007d-6) +// ----------------------------------------------------------------------- + +it('menolak nama sektor kosong', function () { + fakeEditor(); + + $response = $this->from(route('dataspasial.skema', ['sektor' => 'pendidikan'])) + ->put(route('dataspasial.skema.sektor.update', ['sektor' => 101]), [ + 'nama' => '', + ]); + + $response->assertRedirect(route('dataspasial.skema', ['sektor' => 'pendidikan'])); + $response->assertSessionHasErrors('nama'); +}); + +it('tidak mengirim daftar sektor dan meneruskan penolakan keunikan dari API', function () { + // ⚠️ Spec Q13 — keunikan nama adalah tanggung jawab API. OpenKab tidak + // lagi mengirim `nama_sektor_ada` untuk melakukan pemeriksaan sendiri. + fakeEditor(422); + + $response = $this->from(route('dataspasial.skema', ['sektor' => 'pendidikan'])) + ->put(route('dataspasial.skema.sektor.update', ['sektor' => 101]), [ + 'nama' => 'Kesehatan', + ]); + + $response->assertSessionHas('error'); + + Http::assertSent(function (Request $request): bool { + return $request->method() === 'PUT' + && ! array_key_exists('nama_sektor_ada', $request->data()); + }); +}); + +// ----------------------------------------------------------------------- +// US7 #2 — simpan sukses (FR-007d-9) +// ----------------------------------------------------------------------- + +it('menyimpan perubahan sektor tanpa mengirim kode sektor', function () { + fakeEditor(); + + $response = $this->put(route('dataspasial.skema.sektor.update', ['sektor' => 101]), [ + 'nama' => 'Pendidikan Dasar', + 'ikon' => 'school', + ]); + + $response->assertRedirect(route('dataspasial.skema', ['sektor' => 'pendidikan'])); + $response->assertSessionHas('success'); + + // ⚠️ **Kode sektor TIDAK PERNAH dikirim** (FR-007d-5). + Http::assertSent(function (Request $request): bool { + return $request->method() === 'PUT' + && str_contains($request->url(), '/dataspasial/sektor/101') + && $request['nama'] === 'Pendidikan Dasar' + && ! array_key_exists('kode', $request->data()); + }); +}); + +it('meneruskan kegagalan PUT ke service sebagai data tidak tersedia', function () { + fakeEditor(404); + + $result = app(DataSpasialApiService::class)->ubahSektor(101, ['nama' => 'Pendidikan Dasar']); + + expect($result->isUnavailable())->toBeTrue(); + expect($result->userMessage())->toContain('tidak tersedia'); +}); + +// ----------------------------------------------------------------------- +// US7 #5 — endpoint belum ada (FR-007d-7) +// ----------------------------------------------------------------------- + +it('menampilkan data sementara tidak tersedia dan tidak mengubah daftar saat endpoint menolak', function () { + // API hidup membalas 404 untuk PUT sektor. + fakeEditor(404); + + $response = $this->from(route('dataspasial.skema', ['sektor' => 'pendidikan'])) + ->put(route('dataspasial.skema.sektor.update', ['sektor' => 101]), [ + 'nama' => 'Pendidikan Dasar', + ]); + + $response->assertRedirect(route('dataspasial.skema', ['sektor' => 'pendidikan'])); + $response->assertSessionHas('error', fn (string $m): bool => str_contains($m, 'tidak tersedia')); + + // ⚠️ Daftar sektor lama tidak ikut berubah (FR-007d-7). Formulir yang gagal + // **memang** menampilkan kembali nilai yang diketik (`withInput`), jadi + // yang diperiksa adalah blok daftar sektor — bukan keseluruhan halaman. + $halaman = $this->followingRedirects() + ->from(route('dataspasial.skema', ['sektor' => 'pendidikan'])) + ->put(route('dataspasial.skema.sektor.update', ['sektor' => 101]), [ + 'nama' => 'Pendidikan Dasar', + ]); + + preg_match('/]*data-testid="daftar-sektor".*?<\/ul>/s', $halaman->getContent(), $m); + expect($m)->not->toBeEmpty('Daftar sektor tidak dirender'); + expect($m[0])->toContain('Pendidikan'); + expect($m[0])->not->toContain('Pendidikan Dasar'); +}); + +it('menampilkan data sementara tidak tersedia saat endpoint menolak dengan 405', function () { + fakeEditor(405); + + $this->from(route('dataspasial.skema', ['sektor' => 'pendidikan'])) + ->put(route('dataspasial.skema.sektor.update', ['sektor' => 101]), [ + 'nama' => 'Pendidikan Dasar', + ]) + ->assertSessionHas('error', fn (string $m): bool => str_contains($m, 'tidak tersedia')); +}); + +// ----------------------------------------------------------------------- +// Otorisasi (FR-001) +// ----------------------------------------------------------------------- + +it('menolak peran tanpa izin tulis saat mengubah sektor', function () { + fakeEditor(); + + $operator = makeRole('operator impor us7', ['dataspasial-skema-read']); + test()->actingAs($operator, 'web'); + + $this->put(route('dataspasial.skema.sektor.update', ['sektor' => 101]), [ + 'nama' => 'Pendidikan Dasar', + ])->assertForbidden(); +}); diff --git a/tests/Feature/DataSpasial/SkemaEditorIsianTest.php b/tests/Feature/DataSpasial/SkemaEditorIsianTest.php new file mode 100644 index 000000000..3cc37f442 --- /dev/null +++ b/tests/Feature/DataSpasial/SkemaEditorIsianTest.php @@ -0,0 +1,423 @@ + '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', '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?.aturanHitung ?? 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, + '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', 'rujukan' => null], + ['kode' => 'b', 'label' => 'B', 'tipe' => 'teks', 'urutan' => 2, + 'sumber' => 'openSID', 'rujukan' => 'r'], + ], ['sektor' => 'pendidikan']); + + expect($payload['isian'][0]['pemetaan'])->toBeNull() + ->and($payload['isian'][1]['pemetaan'])->toBe([[ + 'id' => 0, + 'sumber' => 'openSID', + 'rujukan_sumber' => 'r', + 'aturan_hitung' => null, + ]]); +}); + +// --------------------------------------------------------------------------- +// 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 opsi NONAKTIF pada tipe non-pilihan (FR-010). + ->toContain('class="form-control form-control-sm input-opsi" multiple size="2" disabled') + // 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 000000000..82c40b463 --- /dev/null +++ b/tests/Feature/DataSpasial/SkemaEditorPemetaanTest.php @@ -0,0 +1,256 @@ + '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, + 'sektorSumber' => null, 'rujukan' => null, + ], $ubah); +} + +/** + * @param array> $isian + * @return array + */ +function drafPemetaan(array $isian): array +{ + return ['sektor' => 'pendidikan', 'isian' => $isian]; +} + +// --------------------------------------------------------------------------- +// US4#1 — sumber = daftar sektor (FR-022) +// --------------------------------------------------------------------------- + +it('menyediakan enum sumber pemetaan dari API resmi', function () { + // ⚠️ VERIFIKASI LANGSUNG (§6a.5): `pemetaan[].sumber` adalah ENUM + // `openSID`/`dataPresisi`/`impor` — BUKAN kode sektor. Keputusan Q2 ("sumber + // = daftar sektor") dicabut. + fakeEndpointEditor( + [fxSektor('1', 'pendidikan', 'Pendidikan'), fxSektor('2', 'kesehatan', 'Kesehatan')], + [fxRiwayatVersi('9', 1, 1, 'draf')], + fxSkemaAktif('9', 1, 'pendidikan', 'Pendidikan', 1, 'draf', [ + fxIsian('jumlah_siswa', ['id' => 31]), + ]) + ); + + $content = $this->get(route('dataspasial.skema', ['sektor' => 'pendidikan', 'skema' => 9])) + ->assertOk() + ->getContent(); + + // Pemilih sumber memakai enum resmi. + expect($content) + ->toContain('data-testid="daftar-sumber"') + ->toContain('openSID') + ->toContain('dataPresisi') + ->toContain('impor'); + + // TIDAK ada endpoint katalog sumber terpisah (FR-022). + Http::assertNotSent(fn ($request) => str_contains($request->url(), 'sumber')); +}); + +// --------------------------------------------------------------------------- +// 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', 'rujukan' => 'luas_wilayah.*']), + ]))->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). + return is_array($pemetaan) + && ($pemetaan[0]['sumber'] ?? null) === 'openSID' + && ($pemetaan[0]['rujukan_sumber'] ?? null) === 'luas_wilayah.*'; + }); +}); + +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 000000000..6bb6f0892 --- /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 000000000..a980ab8c4 --- /dev/null +++ b/tests/Feature/DataSpasial/SkemaEditorSaklarTest.php @@ -0,0 +1,281 @@ + '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', '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_popup'])->toBeTrue() + ->and($payload['isian'][0]['tampil_filter'])->toBeFalse() + ->and($payload['isian'][0]['tampil_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_popup'])->toBeFalse() + ->and($payload['isian'][0]['tampil_filter'])->toBeFalse() + ->and($payload['isian'][0]['tampil_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 = json_decode($request->body(), true); + $isian = $body['isian'][0] ?? []; + + return ($isian['tampil_popup'] ?? null) === true + && ($isian['tampil_filter'] ?? null) === false + && ($isian['tampil_indikator'] ?? null) === true; + }); +}); + +// --------------------------------------------------------------------------- +// 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($request->body(), true); + + // Isian tetap terkirim lengkap; hanya penanda tampilan yang mati. + return ($body['isian'][0]['kode'] ?? null) === 'jumlah_siswa' + && ($body['isian'][0]['tampil_popup'] ?? 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') + // Badge tampil read-only: tanpa tombol pengubah (FR-008a). + ->assertDontSee('btn-pilih-sumber-baris'); +}); diff --git a/tests/Feature/DataSpasial/SkemaEditorSektorTest.php b/tests/Feature/DataSpasial/SkemaEditorSektorTest.php new file mode 100644 index 000000000..72228ccc3 --- /dev/null +++ b/tests/Feature/DataSpasial/SkemaEditorSektorTest.php @@ -0,0 +1,911 @@ + 'https://api.example.test', + 'dataspasial.cache_ttl' => 300, + ]); + + Cache::flush(); + + Setting::updateOrCreate( + ['key' => 'database_gabungan_api_key'], + ['name' => 'API Key', 'value' => 'token-uji'] + ); + + // ⚠️ `setPermissionsTeamId()` bersifat global per request, jadi peran yang + // dibuat TERAKHIR harusLAH pengguna yang sedang login. Karena itu peran + // Operator Impor & Analis OPD disiapkan lebih dulu. + $this->operatorImpor = makeRole('operator impor us1', ['dataspasial-impor-read', 'dataspasial-impor-write']); + $this->analisOpd = makeRole('analis opd us1', ['dataspasial-peta-read']); + + // Admin Skema = pengguna AKTIF untuk mayoritas test. + $this->adminSkema = actingAsRole('admin skema us1', [ + 'dataspasial-skema-read', + 'dataspasial-skema-write', + 'dataspasial-skema-edit', + 'dataspasial-skema-delete', + ]); +}); + +/** + * @param array $permissions + */ +function actingAsRole(string $teamName, array $permissions): User +{ + $user = makeRole($teamName, $permissions); + + test()->actingAs($user, 'web'); + + return $user; +} + +/** + * Beralih ke pengguna yang disiapkan pada `beforeEach`. + * + * `setPermissionsTeamId()` bersifat global, jadi konteks izin harus ikut + * dipindahkan agar spatie teams menyelesaikan peran pengguna yang benar. + */ +function actingAs(User $user): void +{ + setPermissionsTeamId($user->teams()->first()?->id); + + test()->actingAs($user, 'web'); +} + +/** + * @return array + */ +function itemSektor(string $id, string $kode, string $nama, string $status = 'aktif'): array +{ + return [ + 'type' => 'ds_sektor', + 'id' => $id, + 'attributes' => ['kode' => $kode, 'nama' => $nama, 'ikon' => null, 'status' => $status], + ]; +} + +/** + * @return array + */ +/** + * Entri riwayat versi — bentuk nyata `GET /skema/riwayat` (Q9). + */ +function itemRiwayat(string $id, int $sektorId, int $versi, string $status): array +{ + return [ + 'type' => 'riwayat versi skema', + 'id' => $id, + 'attributes' => [ + 'sektor_id' => $sektorId, + 'nomor_versi' => $versi, + '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 /skema?sektor=` (K2/K3). + * + * @param array> $isian + * @return array + */ +function itemSkemaAktif(string $id, string $sektorKode, string $sektorNama, int $sektorId, int $versi, 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' => $versi, + 'status' => $status, + 'catatan_perubahan' => 'Draf uji.', + 'diterbitkan_pada' => $status === 'terbit' ? '2025-10-01T09:00:00+07:00' : null, + 'diterbitkan_oleh' => null, + ], + 'isian' => $isian, + ], + ]; +} + +/** + * @param array> $items + * @return array + */ +function collection(array $items, int $total = 0): array +{ + return [ + 'data' => $items, + 'meta' => ['pagination' => [ + 'total' => $total ?: count($items), + 'count' => count($items), + 'per_page' => 200, + 'current_page' => 1, + 'total_pages' => 1, + ]], + 'links' => [], + ]; +} + +/** + * Palsukan daftar sektor + riwayat versi + versi aktif. + * + * ⚠️ Q9: daftar skema diambil dari `/skema/riwayat`, sedangkan isian dari + * `/skema?sektor=`. Keduanya harus dipalsukan terpisah. + * + * @param array> $sektor + * @param array> $riwayat + */ +function fakeSektorDanSkema(array $sektor, array $riwayat = [], array $skemaAktif = []): void +{ + Http::fake([ + '*/api/v1/config/kabupaten*' => Http::response(['data' => []], 200), + '*/api/v1/config/kecamatan*' => Http::response(['data' => []], 200), + '*/api/v1/config/desa*' => Http::response(['data' => []], 200), + '*/api/v1/dataspasial/sektor*' => Http::response(collection($sektor), 200), + // Q9: daftar seluruh versi. Callback menyaring berdasarkan + // `sektor_id` supaya "hanya versi sektor terpilih yang tampil" + // benar-benar teruji (bukan sekadar satu daftar global). + '*/api/v1/dataspasial/skema/riwayat*' => function (Request $request) use ($sektor, $riwayat) { + parse_str((string) parse_url($request->url(), PHP_URL_QUERY), $query); + $kode = (string) ($query['sektor'] ?? ''); + + $idSektor = null; + foreach ($sektor as $s) { + if (($s['attributes']['kode'] ?? null) === $kode) { + $idSektor = (int) $s['id']; + break; + } + } + + $terfilter = $riwayat === [] ? [] : array_values(array_filter( + $riwayat, + fn (array $r): bool => $idSektor === null + || (int) ($r['attributes']['sektor_id'] ?? 0) === $idSektor + )); + + return Http::response(collection($terfilter), 200); + }, + + // Versi aktif — isian diambil dari sini. + '*/api/v1/dataspasial/skema?*' => Http::response( + $skemaAktif === [] ? collection([]) : $skemaAktif, 200 + ), + '*/api/v1/dataspasial/skema/*' => Http::response($skemaAktif, 200), + '*' => Http::response(collection([]), 200), + ]); +} + +/** + * SALINAN respons nyata `GET /dataspasial/skema/riwayat?sektor=SK0` + * (Q9). Endpoint ini berbeda bentuk dari `skema data spasial`: + * datar, tanpa `sektor`, dan tiap entri memuat `nomor_versi` langsung. + * + * @return array + */ +function riwayatNyata(int $sektorId = 4234): array +{ + return [ + 'data' => [ + [ + 'type' => 'riwayat versi skema', + 'id' => '8143', + 'attributes' => [ + 'sektor_id' => $sektorId, + 'nomor_versi' => 3, + 'status' => 'terbit', + 'catatan_perubahan' => 'Versi terbit', + 'diterbitkan_pada' => '2025-10-05T14:00:00.000000Z', + 'diterbitkan_oleh' => 'Budi Santoso', + ], + ], + [ + 'type' => 'riwayat versi skema', + 'id' => '8142', + 'attributes' => [ + 'sektor_id' => $sektorId, + 'nomor_versi' => 1, + 'status' => 'draf', + 'catatan_perubahan' => 'Versi draf awal', + 'diterbitkan_pada' => null, + 'diterbitkan_oleh' => null, + ], + ], + ], + ]; +} + +// --------------------------------------------------------------------------- +// US1#1 — daftar sektor +// --------------------------------------------------------------------------- + +it('menampilkan daftar sektor beserta label versi atau draf', function () { + fakeSektorDanSkema( + [itemSektor('1', 'pendidikan', 'Pendidikan'), itemSektor('2', 'kesehatan', 'Kesehatan')], + [itemRiwayat('9', 1, 3, 'draf'), itemRiwayat('5', 1, 2, 'terbit')] + ); + + $this->get(route('dataspasial.skema')) + ->assertOk() + ->assertSee('Pendidikan') + ->assertSee('Kesehatan') + ->assertSee('Kode sektor'); +}); + +it('menampilkan keadaan kosong saat DataSpasial belum memiliki sektor', function () { + fakeSektorDanSkema([]); + + $this->get(route('dataspasial.skema')) + ->assertOk() + ->assertSee('Belum ada data') + ->assertDontSee('Data sementara tidak tersedia'); +}); + +// --------------------------------------------------------------------------- +// US1#2 / #7 — daftar skema hanya setelah sektor dipilih (FR-007a) +// --------------------------------------------------------------------------- + +it('tidak menampilkan daftar skema sebelum admin memilih sektor', function () { + fakeSektorDanSkema( + [itemSektor('1', 'pendidikan', 'Pendidikan')], + [itemRiwayat('9', 1, 3, 'draf')] + ); + + $content = $this->get(route('dataspasial.skema'))->assertOk()->getContent(); + + // Penanda daftar skema belum boleh muncul saat belum ada sektor terpilih. + expect($content)->toContain('data-testid="panel-skema-belum-dipilih"'); +}); + +it('menampilkan daftar skema setelah admin memilih satu sektor', function () { + fakeSektorDanSkema( + [itemSektor('1', 'pendidikan', 'Pendidikan')], + [itemRiwayat('9', 1, 3, 'draf'), itemRiwayat('5', 1, 2, 'terbit')] + ); + + $content = $this->get(route('dataspasial.skema', ['sektor' => 'pendidikan']))->assertOk()->getContent(); + + expect($content) + ->toContain('data-testid="panel-skema"') + ->toContain('Versi 3') + ->toContain('Draf berjalan') + ->toContain('Versi 2') + ->toContain('Terbit'); +}); + +it('menampilkan daftar skema milik sektor yang dipilih saja', function () { + fakeSektorDanSkema( + [itemSektor('1', 'pendidikan', 'Pendidikan'), itemSektor('2', 'kesehatan', 'Kesehatan')], + [itemRiwayat('9', 1, 3, 'draf'), itemRiwayat('8', 2, 1, 'draf')] + ); + + $kesehatan = $this->get(route('dataspasial.skema', ['sektor' => 'kesehatan']))->assertOk()->getContent(); + + expect($kesehatan) + ->toContain('Versi 1') + ->not->toContain('Versi 3'); +}); + +// --------------------------------------------------------------------------- +// US1#3 — skema terbit baca-saja (FR-008a) +// --------------------------------------------------------------------------- + +it('menampilkan skema terbit dalam keadaan baca-saja dan menonaktifkan simpan', function () { + fakeSektorDanSkema( + [itemSektor('1', 'pendidikan', 'Pendidikan')], + [itemRiwayat('5', 1, 2, 'terbit')] + ); + + $content = $this->get(route('dataspasial.skema', ['sektor' => 'pendidikan']))->assertOk()->getContent(); + + expect($content) + ->toContain('data-status-editor="terbit"') + ->toContain('Skema versi terbit hanya dapat dibaca.'); +}); + +it('menampilkan skema draf dalam keadaan dapat disunting', function () { + fakeSektorDanSkema( + [itemSektor('1', 'pendidikan', 'Pendidikan')], + [itemRiwayat('9', 1, 3, 'draf')] + ); + + $content = $this->get(route('dataspasial.skema', ['sektor' => 'pendidikan']))->assertOk()->getContent(); + + expect($content)->toContain('data-status-editor="draf"'); +}); + +// --------------------------------------------------------------------------- +// FR-007a-1 / SC-013 (spec Q7) — kunjungan pertama HANYA memanggil daftar sektor +// --------------------------------------------------------------------------- + +it('hanya memanggil daftar sektor pada kunjungan pertama', function () { + Http::fake([ + '*/api/v1/config/*' => Http::response(['data' => []], 200), + '*/api/v1/dataspasial/sektor*' => Http::response(collection([ + itemSektor('1', 'pendidikan', 'Pendidikan'), + itemSektor('2', 'kesehatan', 'Kesehatan'), + ]), 200), + '*/api/v1/dataspasial/*' => Http::response(collection([ + itemRiwayat('9', 1, 3, 'draf'), + ]), 200), + ]); + + $this->get(route('dataspasial.skema'))->assertOk(); + + // Tepat satu pemanggilan baca DataSpasial: daftar sektor. + Http::assertSentCount(2); // 1 sektor + 1 config/sidebar yang di-fake terpisah + + $calls = Http::recorded()->map(fn ($pair) => [ + 'method' => $pair[0]->method(), + 'path' => parse_url($pair[0]->url(), PHP_URL_PATH), + ]); + + $callsDataSpasial = $calls->filter(fn ($call) => str_contains($call['path'], '/dataspasial/'))->values(); + + expect($callsDataSpasial)->toHaveCount(1) + ->and($callsDataSpasial[0]['path'])->toContain('/dataspasial/sektor'); +}); + +it('tidak pernah memanggil daftar skema maupun detail skema tanpa sektor terpilih', function () { + Http::fake([ + '*/api/v1/config/*' => Http::response(['data' => []], 200), + '*/api/v1/dataspasial/*' => Http::response(collection([ + itemSektor('1', 'pendidikan', 'Pendidikan'), + itemRiwayat('9', 1, 3, 'draf'), + ]), 200), + ]); + + $this->get(route('dataspasial.skema'))->assertOk(); + + // Endpoint ini membutuhkan sektor dan DIPASTIKAN GAGAL bila dipanggil + // tanpa parameter — jadi tidak boleh terpanggil sama sekali. + Http::assertNotSent(fn ($request) => str_contains($request->url(), '/dataspasial/skema')); +}); + +it('memanggil daftar skema dengan parameter sektor setelah sektor dipilih', function () { + Http::fake([ + '*/api/v1/config/*' => Http::response(['data' => []], 200), + '*/api/v1/dataspasial/sektor*' => Http::response(collection([ + itemSektor('1', 'pendidikan', 'Pendidikan'), + ]), 200), + '*/api/v1/dataspasial/skema*' => Http::response(collection([ + itemRiwayat('9', 1, 3, 'draf'), + ]), 200), + '*' => Http::response(collection([]), 200), + ]); + + $this->get(route('dataspasial.skema', ['sektor' => 'pendidikan']))->assertOk(); + + Http::assertSent(function ($request) { + $path = parse_url($request->url(), PHP_URL_PATH); + + if (! str_contains($path, '/dataspasial/skema')) { + return false; + } + + // WAJIB menyertakan penyaring sektor dengan NAMA `sektor`. + // ⚠️ VERIFIKASI LANGSUNG: `sektor_id` membalas 422 + // `{"errors":{"sektor":["Isian sektor wajib diisi."]}}` (kontrak §6a.4 K1). + parse_str((string) parse_url($request->url(), PHP_URL_QUERY), $query); + + return ($query['sektor'] ?? null) === '1' + && ! array_key_exists('sektor_id', $query); + }); +}); + +// --------------------------------------------------------------------------- +// US1#6 — sumber data bermasalah (FR-005) +// --------------------------------------------------------------------------- + +it('menampilkan pesan data sementara tidak tersedia saat sumber bermasalah', function () { + Http::fake([ + '*/api/v1/config/*' => Http::response(['data' => []], 200), + '*' => Http::response(['message' => 'Modul DataSpasial belum terpasang'], 503), + ]); + + $this->get(route('dataspasial.skema')) + ->assertOk() + ->assertSee('Data sementara tidak tersedia') + ->assertDontSee('Stack trace'); +}); + +it('menyembunyikan seluruh editor termasuk tombol simpan ketika sumber tidak tersedia', function () { + Http::fake([ + '*/api/v1/config/*' => Http::response(['data' => []], 200), + '*' => Http::response(['message' => 'Modul DataSpasial belum terpasang'], 503), + ]); + + $content = $this->get(route('dataspasial.skema'))->assertOk()->getContent(); + + // Komponen `` mengganti SELURUH area editor dengan + // pesan (R10). Karena tidak ada editor yang dirender, tidak mungkin ada + // tombol simpan yang aktif — inilah bentuk "nonaktif" yang dimaksud FR-005. + expect($content) + ->toContain('Data sementara tidak tersedia') + ->not->toContain('data-status-editor=') + ->not->toContain('id="btn-simpan-draf"'); +}); + +// --------------------------------------------------------------------------- +// US1#12 — validasi nama sektor bentrok (FR-007f) +// --------------------------------------------------------------------------- + +it('menolak nama sektor yang kosong tanpa mengirim permintaan', function () { + fakeSektorDanSkema([itemSektor('1', 'pendidikan', 'Pendidikan')]); + + $this->post(route('dataspasial.skema.sektor.store'), ['nama' => '']) + ->assertSessionHasErrors('nama'); + + Http::assertNotSent(fn ($request) => str_contains($request->url(), '/dataspasial/sektor') + && $request->method() === 'POST'); +}); + +it('tidak memeriksa keunikan nama sektor di sisi OpenKab dan meneruskan penolakan API', function () { + // ⚠️ Spec Q13 — keunikan nama sektor adalah tanggung jawab API. OpenKab + // TIDAK boleh menolak lebih dulu; ia harus mengirim dan menampilkan + // penjelasan yang dikembalikan API. + // ⚠️ `Http::fake()` FIFO + awalan method diperlakukan sebagai glob — + // pemisahan GET/POST dilakukan di dalam closure. + Http::fake([ + '*/api/v1/config/*' => Http::response(['data' => []], 200), + '*/api/v1/dataspasial/sektor*' => function ($request) { + if ($request->method() === 'POST') { + return Http::response([ + 'message' => 'Nama sektor sudah digunakan.', + 'errors' => ['nama' => ['Nama sektor sudah digunakan.']], + ], 422); + } + + return Http::response(fakeKoleksi([itemSektor('1', 'pendidikan', 'Pendidikan')]), 200); + }, + '*' => Http::response(['data' => []], 200), + ]); + + $this->post(route('dataspasial.skema.sektor.store'), ['nama' => 'Pendidikan']) + ->assertSessionHas('error'); +}); + +// ⚠️ Q13 — OpenKab tidak boleh mengirim daftar sektor untuk memeriksa keunikan +// sendiri; keunikan sepenuhnya milik API. +it('tidak mengirim nama_sektor_ada ke API saat membuat sektor', function () { + Http::fake([ + '*/api/v1/config/*' => Http::response(['data' => []], 200), + '*/api/v1/dataspasial/sektor*' => function ($request) { + return $request->method() === 'POST' + ? Http::response(['data' => ['id' => '1', 'attributes' => ['kode' => 'pendidikan']]], 201) + : Http::response(fakeKoleksi([itemSektor('1', 'pendidikan', 'Pendidikan')]), 200); + }, + '*' => Http::response(['data' => []], 200), + ]); + + $this->post(route('dataspasial.skema.sektor.store'), [ + 'nama' => 'Pendidikan', + // Field ini sengaja dikirim untuk membuktikan servers mengabaikannya. + 'nama_sektor_ada' => ['Pendidikan', 'Kesehatan'], + ])->assertSessionHas('success'); + + Http::assertSent(function ($request) { + if ($request->method() !== 'POST' || ! str_contains($request->url(), '/dataspasial/sektor')) { + return false; + } + + return ! array_key_exists('nama_sektor_ada', $request->data()) + && $request['nama'] === 'Pendidikan'; + }); +}); + +it('menolak nama sektor yang hanya berisi spasi', function (string $nama) { + fakeSektorDanSkema([itemSektor('1', 'pendidikan', 'Pendidikan')]); + + // `TrimStrings` menormalkan spasi tepi menjadi kosong, lalu `required` menolak. + $this->post(route('dataspasial.skema.sektor.store'), ['nama' => $nama]) + ->assertSessionHasErrors('nama'); +})->with(['spasi saja' => ' ', 'spasi dan tab' => " \t "]); + +it('menerima nama sektor dengan spasi tepi dan menormalkannya', function () { + Http::fake([ + '*/api/v1/config/*' => Http::response(['data' => []], 200), + '*/api/v1/dataspasial/sektor' => Http::response([ + 'success' => true, + 'data' => ['type' => 'ds_sektor', 'id' => '7', 'attributes' => ['kode' => 'kesehatan', 'nama' => 'Kesehatan']], + ], 201), + '*' => Http::response(collection([itemSektor('7', 'kesehatan', 'Kesehatan')]), 200), + ]); + + $this->post(route('dataspasial.skema.sektor.store'), ['nama' => ' Kesehatan ']) + ->assertSessionHasNoErrors(); + + // Spasi tepi TIDAK ikut dikirim ke API. + Http::assertSent(function ($request) { + return $request->method() === 'POST' + && str_contains($request->url(), '/dataspasial/sektor') + && ($request->data()['nama'] ?? null) === 'Kesehatan'; + }); +}); + +// --------------------------------------------------------------------------- +// US1#8 — pembuatan sektor (FR-007d-3, U1) +// --------------------------------------------------------------------------- + +it('menyimpan sektor baru dan menampilkan kodenya sebagai baca-saja', function () { + Http::fake([ + '*/api/v1/config/*' => Http::response(['data' => []], 200), + '*/api/v1/dataspasial/sektor' => Http::response([ + 'success' => true, + 'message' => 'Sektor berhasil dibuat.', + 'data' => [ + 'type' => 'ds_sektor', + 'id' => '7', + 'attributes' => ['id' => 7, 'kode' => 'kesehatan', 'nama' => 'Kesehatan', 'ikon' => null, 'status' => 'aktif'], + ], + ], 201), + '*' => Http::response(collection([itemSektor('7', 'kesehatan', 'Kesehatan')]), 200), + ]); + + $this->post(route('dataspasial.skema.sektor.store'), ['nama' => 'Kesehatan']) + ->assertRedirect() + ->assertSessionHas('success'); +}); + +it('menampilkan pesan data sementara tidak tersedia bila endpoint sektor belum ada di API', function () { + Http::fake([ + '*/api/v1/config/*' => Http::response(['data' => []], 200), + '*/api/v1/dataspasial/sektor' => Http::response(['message' => 'Not Found'], 404), + '*' => Http::response(collection([itemSektor('1', 'pendidikan', 'Pendidikan')]), 200), + ]); + + $response = $this->post(route('dataspasial.skema.sektor.store'), ['nama' => 'Kesehatan']); + + $response->assertSessionHas('error'); + expect(session('error'))->toContain('Data sementara tidak tersedia'); +}); + +it('menampilkan pesan yang dapat ditindaklanjuti, bukan halaman error saat pembuatan sektor ditolak API', function () { + Http::fake([ + '*/api/v1/config/*' => Http::response(['data' => []], 200), + '*/api/v1/dataspasial/sektor' => Http::response([ + 'success' => false, + 'message' => 'Validasi gagal.', + 'errors' => ['nama' => ['nama sudah dipakai sektor lain.']], + ], 422), + '*' => Http::response(collection([itemSektor('1', 'pendidikan', 'Pendidikan')]), 200), + ]); + + $this->post(route('dataspasial.skema.sektor.store'), ['nama' => 'Pendidikan']) + ->assertRedirect() + ->assertSessionHas('error'); +}); + +// --------------------------------------------------------------------------- +// FR-001 — kontrol akses (US1 stakeholders) +// --------------------------------------------------------------------------- + +it('menolak operator impor dan analis opd pada halaman editor skema', function (string $teamName) { + fakeSektorDanSkema([itemSektor('1', 'pendidikan', 'Pendidikan')]); + + actingAs($this->{$teamName}); + + $this->get(route('dataspasial.skema'))->assertForbidden(); +})->with(['operatorImpor', 'analisOpd']); + +it('menolak operator impor dan analis opd pada endpoint tulis skema', function (string $teamName) { + Http::fake(); + + actingAs($this->{$teamName}); + + $this->post(route('dataspasial.skema.draf.store'), [ + 'sektor' => 'pendidikan', + 'isian' => [[ + 'kode' => 'jumlah_siswa', + 'label' => 'Jumlah Siswa', + 'tipe' => 'angka', + 'urutan' => 1, + ]], + ])->assertForbidden(); + + $this->post(route('dataspasial.skema.sektor.store'), ['nama' => 'Kesehatan'])->assertForbidden(); +})->with(['operatorImpor', 'analisOpd']); + +// --------------------------------------------------------------------------- +// FR-041 — tombol Terbitan AKTIF saat skema berstatus draf (Q11) +// +// ⚠️ Bertentangan dengan behaviour lama FR-040/Q1 yang menyebut KAB-03. +// Frontend masih menjaga dirty-guard FR-045 saat ada perubahan belum disimpan. +// --------------------------------------------------------------------------- + +it('menampilkan tombol Terbitan dalam keadaan aktif untuk draf dan tanpa catatan KAB-03', function () { + fakeSektorDanSkema( + [itemSektor('1', 'pendidikan', 'Pendidikan')], + [itemRiwayat('9', 1, 3, 'draf')] + ); + + $content = $this->get(route('dataspasial.skema', ['sektor' => 'pendidikan']))->assertOk()->getContent(); + + expect($content) + ->toContain('id="btn-terbitan"') + ->not->toContain('KAB-03'); + + preg_match('/]*id="btn-terbitan"[^>]*>/', $content, $m); + expect($m)->not->toBeEmpty(); + expect($m[0])->not->toContain('disabled'); +}); + +it('menampilkan tombol Terbitan nonaktif ketika skema sudah terbit', function () { + fakeSektorDanSkema( + [itemSektor('1', 'pendidikan', 'Pendidikan')], + [itemRiwayat('9', 1, 3, 'terbit')] + ); + + $content = $this->get(route('dataspasial.skema', ['sektor' => 'pendidikan']))->assertOk()->getContent(); + + preg_match('/]*id="btn-terbitan"[^>]*>/', $content, $m); + expect($m)->not->toBeEmpty(); + expect($m[0])->toContain('disabled'); +}); + +it('tidak pernah memanggil endpoint terbitan dari halaman editor', function () { + fakeSektorDanSkema( + [itemSektor('1', 'pendidikan', 'Pendidikan')], + [itemRiwayat('9', 1, 3, 'draf')] + ); + + $this->get(route('dataspasial.skema', ['sektor' => 'pendidikan', 'skema' => 9]))->assertOk(); + + Http::assertNotSent(fn ($request) => str_contains($request->url(), '/terbit')); +}); + +// --------------------------------------------------------------------------- +// Q9 — Daftar skema per sektor (endpoint riwayat versi) +// --------------------------------------------------------------------------- + +it('mengambil daftar skema dari endpoint riwayat versi setelah sektor dipilih', function () { + Http::fake([ + '*/api/v1/config/*' => Http::response(['data' => []], 200), + '*/api/v1/dataspasial/sektor*' => Http::response(collection([ + itemSektor('1', 'pendidikan', 'Pendidikan'), + ]), 200), + '*/api/v1/dataspasial/skema/riwayat*' => Http::response(riwayatNyata(), 200), + '*' => Http::response(collection([]), 200), + ]); + + $this->get(route('dataspasial.skema', ['sektor' => 'pendidikan']))->assertOk(); + + // Q9: daftar WAJIB dari /skema/riwayat dengan KODE sektor. + Http::assertSent(function ($request) { + $path = parse_url($request->url(), PHP_URL_PATH); + + if (! str_contains($path, '/dataspasial/skema/riwayat')) { + return false; + } + + parse_str((string) parse_url($request->url(), PHP_URL_QUERY), $query); + + return ($query['sektor'] ?? null) === 'pendidikan'; + }); +}); + +it('tidak memanggil riwayat versi ketika belum ada sektor terpilih', function () { + Http::fake([ + '*/api/v1/config/*' => Http::response(['data' => []], 200), + '*/api/v1/dataspasial/sektor*' => Http::response(collection([ + itemSektor('1', 'pendidikan', 'Pendidikan'), + ]), 200), + '*' => Http::response(collection([]), 200), + ]); + + $this->get(route('dataspasial.skema'))->assertOk(); + + Http::assertNotSent(fn ($request) => str_contains($request->url(), '/skema/riwayat')); +}); + +it('menampilkan seluruh versi skema sektor pada panel skema', function () { + Http::fake([ + '*/api/v1/config/*' => Http::response(['data' => []], 200), + '*/api/v1/dataspasial/sektor*' => Http::response(collection([ + itemSektor('1', 'pendidikan', 'Pendidikan'), + ]), 200), + '*/api/v1/dataspasial/skema/riwayat*' => Http::response(riwayatNyata(), 200), + '*' => Http::response(collection([]), 200), + ]); + + $content = $this->get(route('dataspasial.skema', ['sektor' => 'pendidikan']))->assertOk()->getContent(); + + // Harus naik: versi 3 (terbit) DAN versi 1 (draf) sama-sama tampil. + expect($content) + ->toContain('data-testid="panel-skema"') + ->toContain('Versi 3') + ->toContain('Versi 1') + ->toContain('Terbit') + ->toContain('Draf berjalan'); +}); + +it('memetakan respons riwayat versi nyata ke daftar skema', function () { + $editor = app(SkemaEditorService::class); + + $hasil = array_map( + fn (array $item): array => $editor->petakanRiwayatVersi($item), + riwayatNyata()['data'] + ); + + expect($hasil)->toHaveCount(2) + ->and($hasil[0]['id'])->toBe(8143) + ->and($hasil[0]['nomorVersi'])->toBe(3) + ->and($hasil[0]['status'])->toBe('terbit') + ->and($hasil[0]['diterbitkanOleh'])->toBe('Budi Santoso') + ->and($hasil[1]['nomorVersi'])->toBe(1) + ->and($hasil[1]['status'])->toBe('draf') + ->and($hasil[1]['diterbitkanPada'])->toBeNull(); +}); + +// --------------------------------------------------------------------------- +// Q8 — Kontrak terverifikasi langsung ke API NYATA (2026-10-15) +// +// Fixture di bawah adalah SALINAN PERSIS dari respons instance API Satu Data +// yang hidup (`GET /dataspasial/sektor` dan `GET /dataspasial/skema?sektor=4234`). +// Test ini mengunci discoveries yang tidak ada pada draft openapi-fondasi.yaml: +// K1 param `sektor` (bukan `sektor_id`) +// K2 tidak ada endpoint detail; isian ikut di respons skema +// K3 versi/status di `attributes.versi_skema` +// K4 nama resmi penanda: `tampil_popup` / `tampil_filter` / `tampil_indikator` +// --------------------------------------------------------------------------- + +it('memetakan respons API nyata beserta penanda tampil yang resmi', function () { + // SALINAN respons nyata. + $respons = [ + 'type' => 'skema data spasial', + 'id' => '8143', + 'attributes' => [ + 'sektor' => ['id' => 4234, 'kode' => 'SK0', 'nama' => 'Sektor 0'], + 'versi_skema' => [ + 'id' => 8143, + 'sektor_id' => 4234, + 'nomor_versi' => 3, + 'status' => 'terbit', + 'catatan_perubahan' => 'Versi terbit', + 'diterbitkan_pada' => '2025-10-05T14:00:00.000000Z', + 'diterbitkan_oleh' => 'Budi Santoso', + ], + 'isian' => [[ + 'id' => 8484, + 'kode' => 'lebar_jalan', + 'label' => 'Lebar Jalan', + 'tipe' => 'angka', + 'wajib' => true, + 'satuan' => 'meter', + 'opsi' => null, + 'aturan_validasi' => null, + 'deskripsi' => null, + 'urutan' => 1, + 'diarsipkan' => false, + 'tampil_popup' => true, + 'tampil_filter' => false, + 'tampil_indikator' => false, + 'pemetaan' => [[ + 'id' => 3951, + 'sumber' => 'openSID', + 'rujukan_sumber' => 'tbl_jalan.lebar', + 'aturan_hitung' => null, + ]], + ]], + ], + ]; + + $editor = app(SkemaEditorService::class); + + // K3: versi & status dibaca dari `versi_skema`. + $skema = $editor->petakanSektorSkema($respons); + + expect($skema['id'])->toBe(8143) + ->and($skema['sektorId'])->toBe(4234) + ->and($skema['sektorKode'])->toBe('SK0') + ->and($skema['nomorVersi'])->toBe(3) + ->and($skema['status'])->toBe('terbit') + ->and($skema['jumlahIsian'])->toBe(1); + + $isian = $editor->petakanDaftarIsian($respons['attributes']['isian']); + + // K4: nama resmi `tampil_*`, bukan asumsi `popup_peta`/`filter`/`indikator`. + expect($isian[0]['popupPeta'])->toBeTrue() + ->and($isian[0]['sebagaiFilter'])->toBeFalse() + ->and($isian[0]['sebagaiIndikator'])->toBeFalse(); + + // Pemetaan tertanam: `sumber` adalah enum API (`openSID`), bukan kode sektor. + expect($isian[0]['sumber'])->toBe('openSID') + ->and($isian[0]['rujukan'])->toBe('tbl_jalan.lebar'); +}); + +it('memetakan counter sektor nyata untuk label versi dan draf', function () { + // SALINAN respons nyata `GET /dataspasial/sektor`. + $responsSektor = [[ + 'type' => 'daftar sektor', + 'id' => '4234', + 'attributes' => [ + 'kode' => 'SK0', + 'nama' => 'Sektor 0', + 'ikon' => 'fa-cube', + 'deskripsi' => 'Untuk unit test', + 'urutan' => 0, + 'status' => 'aktif', + 'jumlah_versi_skema' => 2, + 'jumlah_versi_terbit' => 1, + ], + ]]; + + $editor = app(SkemaEditorService::class); + $daftar = $editor->petakanDaftarSektor($responsSektor, []); + + expect($daftar[0]['jumlahSkema'])->toBe(2) + ->and($daftar[0]['jumlahTerbit'])->toBe(1) + // Sudah ada versi terbit → label "Terbit", bukan "Draf". + ->and($daftar[0]['adaDraf'])->toBeFalse(); +}); + +it('menyusun badan draf memakai nama field resmi API', function () { + $editor = app(SkemaEditorService::class); + + $payload = $editor->composeDrafPayload([[ + 'kode' => 'lebar_jalan', + 'label' => 'Lebar Jalan', + 'tipe' => 'angka', + 'urutan' => 1, + 'popupPeta' => true, + 'sebagaiFilter' => false, + 'sebagaiIndikator' => true, + 'sumber' => 'openSID', + 'rujukan' => 'tbl_jalan.lebar', + ]], ['sektor' => 'SK0']); + + expect($payload['sektor'])->toBe('SK0') + ->and($payload['isian'][0]['tampil_popup'])->toBeTrue() + ->and($payload['isian'][0]['tampil_filter'])->toBeFalse() + ->and($payload['isian'][0]['tampil_indikator'])->toBeTrue() + // Pemetaan dikirim sebagai ARRAY di dalam isian (bentuk nyata §6a.3). + ->and($payload['isian'][0]['pemetaan'])->toBe([[ + 'id' => 0, + 'sumber' => 'openSID', + 'rujukan_sumber' => 'tbl_jalan.lebar', + 'aturan_hitung' => null, + ]]); +}); + +it('tidak mengirim pemetaan untuk isian yang diisi manual', function () { + $editor = app(SkemaEditorService::class); + + $payload = $editor->composeDrafPayload([ + ['kode' => 'a', 'label' => 'A', 'tipe' => 'teks', 'urutan' => 1, 'sumber' => 'manual'], + ], ['sektor' => 'SK0']); + + expect($payload['isian'][0]['pemetaan'])->toBeNull(); +}); diff --git a/tests/Feature/DataSpasial/SkemaEditorTerbitTest.php b/tests/Feature/DataSpasial/SkemaEditorTerbitTest.php new file mode 100644 index 000000000..ad78c5f82 --- /dev/null +++ b/tests/Feature/DataSpasial/SkemaEditorTerbitTest.php @@ -0,0 +1,196 @@ + '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 us8', [ + 'dataspasial-skema-read', + 'dataspasial-skema-write', + 'dataspasial-skema-edit', + 'dataspasial-skema-delete', + ]); + + test()->actingAs($this->adminSkema, 'web'); +}); + +/** + * Pasang stub HTTP halaman editor + endpoint terbitan. + * + * ⚠️ `Http::fake()` bersifat FIFO dan awalan `POST ` diperlakukan sebagai glob + * (bukan filter method), sehingga pemisahan dilakukan di dalam closure. + * + * @see SkemaEditorEditSektorTest.php untuk penjelasan lengkap. + * + * @param array> $riwayat + */ +function fakeTerbit(?int $statusTerbit = null, ?array $riwayat = null, string $statusAktif = 'draf'): void +{ + $riwayat ??= [fxRiwayatVersi('901', 101, 1, 'draf')]; + $nomorVersi = (int) ($riwayat[0]['attributes']['nomor_versi'] ?? 1); + + Http::fake([ + '*/api/v1/dataspasial/skema/*/terbit' => function (Request $request) use ($statusTerbit) { + if ($request->method() !== 'POST') { + return Http::response(['data' => []], 200); + } + + return $statusTerbit === null + ? Http::response([ + 'data' => ['id' => '902', 'type' => 'skema', 'attributes' => [ + 'versi_skema' => ['nomor_versi' => 1, 'status' => 'terbit'], + ]], + ], 200) + : Http::response(['message' => 'Gagal'], $statusTerbit); + }, + '*/api/v1/config/*' => Http::response(fakeConfigEndpoint(), 200), + '*/api/v1/dataspasial/sektor*' => Http::response( + fakeKoleksi([fxSektor('101', 'pendidikan', 'Pendidikan')]), 200 + ), + '*/api/v1/dataspasial/skema/riwayat*' => Http::response(fakeKoleksi($riwayat), 200), + '*/api/v1/dataspasial/skema?*' => Http::response( + fxSkemaAktif('101', 101, 'pendidikan', 'Pendidikan', $nomorVersi, $statusAktif, [fxIsian('jumlah_siswa')]), + 200 + ), + '*' => Http::response(['data' => []], 200), + ]); +} + +// ----------------------------------------------------------------------- +// US8 #1 — tombol aktif saat draf (FR-041) +// ----------------------------------------------------------------------- + +it('mengaktifkan tombol terbitkan saat skema berstatus draf', function () { + fakeTerbit(); + + $response = $this->get(route('dataspasial.skema', ['sektor' => 'pendidikan'])); + + $response->assertOk(); + + // Tombol TIDAK lagi memakai atribut `disabled` statis. + preg_match('/]*id="btn-terbitan"[^>]*>/', $response->getContent(), $m); + expect($m)->not->toBeEmpty('Tombol Terbitan tidak dirender'); + expect($m[0])->not->toContain('disabled'); +}); + +it('tidak menyertakan catatan KAB-03 pada tombol terbitkan', function () { + fakeTerbit(); + + $response = $this->get(route('dataspasial.skema', ['sektor' => 'pendidikan'])); + + $response->assertDontSee('KAB-03'); +}); + +// ----------------------------------------------------------------------- +// US8 #4 — skema terbit → nonaktif (FR-008a) +// ----------------------------------------------------------------------- + +it('menonaktifkan tombol terbitkan ketika skema sudah terbit', function () { + fakeTerbit(riwayat: [fxRiwayatVersi('901', 101, 3, 'terbit')], statusAktif: 'terbit'); + + $response = $this->get(route('dataspasial.skema', ['sektor' => 'pendidikan'])); + + $response->assertOk(); + preg_match('/]*id="btn-terbitan"[^>]*>/', $response->getContent(), $m); + expect($m)->not->toBeEmpty(); + expect($m[0])->toContain('disabled'); +}); + +// ----------------------------------------------------------------------- +// US8 #2 — penerbitan sukses +// ----------------------------------------------------------------------- + +it('menerbitkan draf memakai kode sektor pada path', function () { + fakeTerbit(riwayat: [fxRiwayatVersi('902', 101, 1, 'terbit')]); + + $response = $this->from(route('dataspasial.skema', ['sektor' => 'pendidikan'])) + ->post(route('dataspasial.skema.terbit'), [ + 'sektor' => 'pendidikan', + 'skema' => 901, + 'catatan_perubahan' => 'Versi 1 terbit', + ]); + + // Setelah terbit, editor diarahkan ke versi yang baru terbit. + $response->assertRedirect(route('dataspasial.skema', [ + 'sektor' => 'pendidikan', + 'skema' => 902, + ])); + $response->assertSessionHas('success'); + + // ⚠️ `{sektor}` pada path adalah KODE sektor, bukan id numerik. + Http::assertSent(function (Request $request): bool { + return $request->method() === 'POST' + && str_ends_with($request->url(), '/dataspasial/skema/pendidikan/terbit'); + }); +}); + +it('menampilkan data sementara tidak tersedia saat endpoint terbitan 404', function () { + fakeTerbit(404); + + $this->from(route('dataspasial.skema', ['sektor' => 'pendidikan'])) + ->post(route('dataspasial.skema.terbit'), [ + 'sektor' => 'pendidikan', + 'skema' => 901, + ]) + ->assertSessionHas('error', fn (string $m): bool => str_contains($m, 'tidak tersedia')); +}); + +it('menampilkan data sementara tidak tersedia saat endpoint terbitan 405', function () { + fakeTerbit(405); + + $this->from(route('dataspasial.skema', ['sektor' => 'pendidikan'])) + ->post(route('dataspasial.skema.terbit'), [ + 'sektor' => 'pendidikan', + 'skema' => 901, + ]) + ->assertSessionHas('error'); +}); + +// ----------------------------------------------------------------------- +// Otorisasi (FR-001) +// ----------------------------------------------------------------------- + +it('menolak peran tanpa izin tulis saat menerbitkan', function () { + fakeTerbit(); + + $operator = makeRole('operator impor us8', ['dataspasial-skema-read']); + test()->actingAs($operator, 'web'); + + $this->post(route('dataspasial.skema.terbit'), [ + 'sektor' => 'pendidikan', + 'skema' => 901, + ])->assertForbidden(); +}); diff --git a/tests/Feature/DataSpasial/SkemaEditorValidationTest.php b/tests/Feature/DataSpasial/SkemaEditorValidationTest.php new file mode 100644 index 000000000..d0ad30da4 --- /dev/null +++ b/tests/Feature/DataSpasial/SkemaEditorValidationTest.php @@ -0,0 +1,309 @@ + 'https://api.example.test', + 'dataspasial.cache_ttl' => 300, + ]); + + Cache::flush(); + + Setting::updateOrCreate( + ['key' => 'database_gabungan_api_key'], + ['name' => 'API Key', 'value' => 'token-uji'] + ); + + // Admin Skema dengan izin tulis. Izin `dataspasial-skema-*` sudah tersedia + // dari KAB-01 (T001) — test ini TIDAK membuat permission baru. + actingAsAdminSkema(); + + $this->routeDraf = route('dataspasial.skema.draf.store'); +}); + +/** + * Bikin pengguna yang/group punya izin tulis skema (FR-001). + * + * Pola mengikuti `DataSpasialMenuAccessTest` (spatie teams). + */ +function actingAsAdminSkema(string $teamName = 'admin skema uji'): 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'), + ['dataspasial-skema-read', 'dataspasial-skema-write', 'dataspasial-skema-edit', 'dataspasial-skema-delete'] + )); + + $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); + + test()->actingAs($user, 'web'); + + return $user; +} + +/** + * Bentuk isian yang valid; dapat ditimpa per kasus. + * + * @param array $ubah + * @return array + */ +function isianValid(array $ubah = []): array +{ + return array_merge([ + '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); +} + +/** + * @param array> $isian + * @return array + */ +function drafPayload(array $isian, array $tambah = []): array +{ + return array_merge([ + 'sektor' => 'pendidikan', + 'catatan_perubahan' => 'Draf uji.', + ], $tambah, ['isian' => $isian]); +} + +// --------------------------------------------------------------------------- +// FR-017/FR-018 — `kode` DITURUNKAN dari `label` (Q14), unik per versi (Q17) +// +// ⚠️ Admin TIDAK lagi mengetik `kode`. Yang diuji di sini adalah bentuk dari +// nilai yang sudah dibangkitkan klien, plus keunikan `label`/`kode` di dalam +// SATU versi yang dikirim. +// --------------------------------------------------------------------------- + +// ⚠️ Q14 — yang diuji adalah NILAI TURUNAN (hasil slum-bangian di sisi klien), +// karena OpenKab tidak lagi menerima kode yang diketik manual. +it('menolak kode turunan yang tidak sah sebelum mengirim ke API', function (string $kode) { + Http::fake(); + + $this->post($this->routeDraf, drafPayload([isianValid(['kode' => $kode])])) + ->assertSessionHasErrors('isian.0.kode'); + + Http::assertNothingSent(); +})->with([ + 'kosong dari label kosong' => '', + 'kosong dari label simbol' => '', + 'dimulai angka dari label "2Jumlah"' => '2jumlah', + 'mengandung spasi' => 'jumlah siswa', + 'berkas dengan tanda hubung' => 'jumlah-siswa', +]); + +it('menerima kode turunan dari label yang sah', function (string $label, string $kode) { + Http::fake(['*' => Http::response([ + 'data' => ['type' => 'ds_skema_versi', 'id' => '5', 'attributes' => ['nomor_versi' => 1]], + ], 201)]); + + $this->post($this->routeDraf, drafPayload([ + isianValid(['label' => $label, 'kode' => $kode]), + ]))->assertSessionHasNoErrors(); + + Http::assertSent(fn ($request) => str_contains($request->body(), $kode)); +})->with([ + ['Jumlah Siswa', 'jumlah_siswa'], + ['Luas (Ha)', 'luas_ha'], + ['Jumlah', 'jumlah'], + ['Village Code 2', 'village_code_2'], +]); + +// --------------------------------------------------------------------------- +// FR-018/Q17 — keunikan label & kode DI DALAM satu versi +// --------------------------------------------------------------------------- + +it('menolak dua isian berlabel sama dalam satu versi sebelum mengirim ke API', function () { + Http::fake(); + + $this->post($this->routeDraf, drafPayload([ + isianValid(['label' => 'Jumlah Siswa', 'urutan' => 1]), + isianValid(['label' => 'Jumlah Siswa', 'urutan' => 2]), + ]))->assertSessionHasErrors('isian.1.label'); + + Http::assertNothingSent(); +}); + +it('menolak dua isian berlabel berbeda yang dipadatkan menjadi kode sama', function () { + Http::fake(); + + $this->post($this->routeDraf, drafPayload([ + isianValid(['label' => 'Jumlah Siswa', 'kode' => 'jumlah_siswa', 'urutan' => 1]), + isianValid(['label' => 'Jumlah-Siswa', 'kode' => 'jumlah_siswa', 'urutan' => 2]), + ]))->assertSessionHasErrors(); + + Http::assertNothingSent(); +}); + +it('menerima label dan kode berbeda pada beberapa isian dalam satu versi', function () { + Http::fake(['*' => Http::response([ + 'data' => ['type' => 'ds_skema_versi', 'id' => '5', 'attributes' => ['nomor_versi' => 1]], + ], 201)]); + + $this->post($this->routeDraf, drafPayload([ + isianValid(['label' => 'Jumlah Siswa', 'kode' => 'jumlah_siswa', 'urutan' => 1]), + isianValid(['label' => 'Jumlah Guru', 'kode' => 'jumlah_guru', 'urutan' => 2]), + isianValid(['label' => 'Luas Ha', 'kode' => 'luas_ha', 'urutan' => 3]), + ]))->assertSessionHasNoErrors(); +}); + +// ⚠️ Q17 — keunikan berlaku PER VERSI. Label yang sama pada versi berbeda +// **tidak** ditolak, karena satu request = satu versi dan draf berikutnya +// disalin dari versi sebelumnya. +it('tidak menolak label yang sama pada versi berbeda karena keunikan per versi', function () { + Http::fake(['*' => Http::response([ + 'data' => ['type' => 'ds_skema_versi', 'id' => '5', 'attributes' => ['nomor_versi' => 3]], + ], 201)]); + + $response = $this->post($this->routeDraf, drafPayload([ + isianValid(['label' => 'Jumlah Siswa', 'kode' => 'jumlah_siswa', 'urutan' => 1]), + ], ['nomor_versi' => 3])); + + $response->assertSessionHasNoErrors(); + $response->assertSessionHas('success'); +}); + +// --------------------------------------------------------------------------- +// FR-018 — keunikan kode di dalam satu draf +// --------------------------------------------------------------------------- + +it('menolak kode isian bentrok di dalam satu draf sebelum mengirim ke API', function () { + Http::fake(); + + $this->post($this->routeDraf, drafPayload([ + isianValid(['kode' => 'jumlah_siswa', 'urutan' => 1]), + isianValid(['kode' => 'jumlah_siswa', 'label' => 'Jumlah Siswa Putri', 'urutan' => 2]), + ]))->assertSessionHasErrors('isian.1.kode'); + + Http::assertNothingSent(); +}); + +// --------------------------------------------------------------------------- +// FR-019 — isian wajib tipe pilihan & sektor tanpa isian +// --------------------------------------------------------------------------- + +it('menolak isian wajib tipe pilihan tanpa butir pilihan', function () { + Http::fake(); + + $this->post($this->routeDraf, drafPayload([ + isianValid(['kode' => 'jenis_sekolah', 'tipe' => 'pilihan', 'opsi' => [], 'wajib' => true]), + ]))->assertSessionHasErrors('isian.0.opsi'); + + Http::assertNothingSent(); +}); + +it('menolak draf yang menyebabkan sektor memiliki nol isian', function () { + Http::fake(); + + $this->post($this->routeDraf, drafPayload([])) + ->assertSessionHasErrors('isian'); + + Http::assertNothingSent(); +}); + +// --------------------------------------------------------------------------- +// Atribut wajib lain +// --------------------------------------------------------------------------- + +it('menolak isian tanpa kode, tanpa label, dan tanpa tipe', function () { + Http::fake(); + + $this->post($this->routeDraf, drafPayload([ + ['urutan' => 1], + ]))->assertSessionHasErrors(['isian.0.kode', 'isian.0.label', 'isian.0.tipe']); + + Http::assertNothingSent(); +}); + +it('menolak tipe isian yang tidak dikenal', function () { + Http::fake(); + + $this->post($this->routeDraf, drafPayload([ + isianValid(['tipe' => 'warna']), + ]))->assertSessionHasErrors('isian.0.tipe'); + + Http::assertNothingSent(); +}); + +it('menolak draf tanpa identitas sektor', function () { + Http::fake(); + + $this->post($this->routeDraf, drafPayload([isianValid()], ['sektor' => ''])) + ->assertSessionHasErrors('sektor'); + + Http::assertNothingSent(); +}); + +it('menerima semua tipe isian yang didukung', function (string $tipe) { + Http::fake(['*' => Http::response([ + 'data' => ['type' => 'ds_skema_versi', 'id' => '5', 'attributes' => ['nomor_versi' => 1]], + ], 201)]); + + $this->post($this->routeDraf, drafPayload([ + isianValid(['kode' => 'nilai_satu', 'tipe' => $tipe, 'opsi' => $tipe === 'pilihan' ? ['A'] : null]), + ]))->assertSessionHasNoErrors(); +})->with(['angka', 'teks', 'pilihan', 'ya_tidak', 'tanggal']); + +// --------------------------------------------------------------------------- +// FR-020 — tidak ada request terkirim saat validasi gagal +// --------------------------------------------------------------------------- + +it('tidak mengirim request apa pun ke API ketika draf ditolak validasi', function () { + Http::fake(); + + // ⚠️ Q14 — `kode` tidak lagi diketik admin. Yang ditolak di sini adalah + // kode TURUNAN yang tidak sah bentuk, yaitu hasil *slug* dari label + // yang tidak menghasilkan slug valid. + $this->post($this->routeDraf, drafPayload([ + isianValid(['label' => '!!!', 'kode' => '']), + ])); + + Http::assertNothingSent(); +}); diff --git a/tests/Pest.php b/tests/Pest.php index fe001d9eb..0b35ad148 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -4,6 +4,14 @@ uses(TestCase::class)->in('Feature', 'Browser'); +// Fixture bersama editor skema (KAB-02). Berisi SALINAN PERSIS respons API +// Satu Data yang nyata — `openapi-fondasi.yaml` v0.1.0 tidak cocok dengan +// implementasi. Lihat specs/004-pengelola-skema-editor/contracts/skema-editor-api.md §6a. +// +// PSR-4 tidak meng-autoload fungsi biasa, jadi berkas ini di-require manual. +// Bukan test (tidak berakhiran `Test.php`) sehingga runner tidak mengambilnya. +require_once __DIR__.'/Feature/DataSpasial/Fixtures.php'; + pest()->browser()->timeout(30000); beforeEach(function () { diff --git a/tests/TestCase.php b/tests/TestCase.php index 7cbb2436b..fa93f81cf 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -2,11 +2,12 @@ namespace Tests; +use Illuminate\Foundation\Testing\DatabaseTransactions; use Illuminate\Foundation\Testing\TestCase as BaseTestCase; abstract class TestCase extends BaseTestCase { - use CreatesApplication; + use CreatesApplication, DatabaseTransactions; public function actingAsAdmin($admin) { From d0e9c6508a8698640ac907005438925256ac3d46 Mon Sep 17 00:00:00 2001 From: Ahmad Afandi Date: Fri, 16 Oct 2026 09:49:12 +0700 Subject: [PATCH 4/5] perbaikan cara simpan skema --- .../SkemaDataSpasialController.php | 112 +-- .../DataSpasial/StoreDrafSkemaRequest.php | 41 +- .../DataSpasial/StoreSektorRequest.php | 61 -- .../DataSpasial/UpdateSektorRequest.php | 59 -- app/Services/DataSpasialApiService.php | 39 - app/Services/SkemaEditorService.php | 122 ++-- public/js/dataspasial/schema-editor.js | 666 +++++++++++++++--- .../views/dataspasial/skema/index.blade.php | 38 +- .../skema/partials/form-isian.blade.php | 11 +- .../skema/partials/panel-sektor.blade.php | 107 +-- .../skema/partials/panel-skema.blade.php | 78 -- .../skema/partials/pilih-sumber.blade.php | 69 -- .../skema/partials/pratinjau.blade.php | 14 +- .../skema/partials/tabel-isian.blade.php | 77 +- routes/web.php | 16 +- tests/Browser/SmokeSkemaEditorTest.php | 135 +++- .../DataSpasial/DataSpasialApiServiceTest.php | 562 --------------- .../DataSpasial/DataSpasialMenuAccessTest.php | 364 ---------- .../DataSpasial/SkemaEditorDrafTest.php | 313 ++------ .../DataSpasial/SkemaEditorEditSektorTest.php | 250 ------- .../DataSpasial/SkemaEditorIsianTest.php | 14 +- .../DataSpasial/SkemaEditorPemetaanTest.php | 40 +- .../DataSpasial/SkemaEditorSaklarTest.php | 53 +- .../DataSpasial/SkemaEditorSektorTest.php | 611 +++++++--------- .../SkemaEditorSumberNilaiTest.php | 153 ++++ .../DataSpasial/SkemaEditorValidationTest.php | 309 -------- 26 files changed, 1454 insertions(+), 2860 deletions(-) delete mode 100644 app/Http/Requests/DataSpasial/StoreSektorRequest.php delete mode 100644 app/Http/Requests/DataSpasial/UpdateSektorRequest.php delete mode 100644 resources/views/dataspasial/skema/partials/panel-skema.blade.php delete mode 100644 resources/views/dataspasial/skema/partials/pilih-sumber.blade.php delete mode 100644 tests/Feature/DataSpasial/DataSpasialApiServiceTest.php delete mode 100644 tests/Feature/DataSpasial/DataSpasialMenuAccessTest.php delete mode 100644 tests/Feature/DataSpasial/SkemaEditorEditSektorTest.php create mode 100644 tests/Feature/DataSpasial/SkemaEditorSumberNilaiTest.php delete mode 100644 tests/Feature/DataSpasial/SkemaEditorValidationTest.php diff --git a/app/Http/Controllers/DataSpasial/SkemaDataSpasialController.php b/app/Http/Controllers/DataSpasial/SkemaDataSpasialController.php index 9b7801716..276f9eb1e 100644 --- a/app/Http/Controllers/DataSpasial/SkemaDataSpasialController.php +++ b/app/Http/Controllers/DataSpasial/SkemaDataSpasialController.php @@ -48,7 +48,7 @@ protected function viewName(): string } /** - * Data awal untuk kerangka & komponen tiga-keadaan. + * Data awal untuk kerangka editor dua panel. * * Halaman editor memerlukan daftar sektor sebagai sumber kebenarannya, * sehingga `fetch()` membaca sektor (bukan daftar skema seperti kerangka @@ -67,70 +67,44 @@ public function index(Request $request): View $sektorTerpilih = $this->sektorTerpilih($request, $daftarSektor); - // ⚠️ FR-007a-1 — endpoint daftar skema yang membutuhkan parameter sektor - // DIPASTIKAN GAGAL bila dipanggil tanpa sektor. Karena itu setelah - // kunjungan pertama ia **tidak boleh** dipanggil sama sekali, bukan - // sekadar "dipanggil lalu hasilnya diabaikan". - $resultSkema = null; - $skemaSektor = []; - $skemaTerpilih = null; + // ⚠️ 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 = []; - $resultDetail = null; + $skemaTerpilih = null; if ($sektorTerpilih !== null) { - // ⚠️ Q9 — daftar skema diambil dari endpoint RIWAYAT VERSI. - // `GET /skema?sektor=` hanya mengembalikan SATU versi (aktif) sehingga - // tidak bisa menjadi sumber daftar. Parameternya KODE sektor. - $resultRiwayat = $this->dataspasial->riwayatSkema([ + $resultSkema = $this->dataspasial->listSkema([ 'sektor' => $sektorTerpilih['kode'], ]); - $skemaSektor = array_map( - fn (array $item): array => $this->editor->petakanRiwayatVersi($item), - $resultRiwayat->data - ); - - // Urut dari versi terbaru (API sudah mengurutkan `nomor_versi` DESC). - $skemaTerpilih = $this->skemaTerpilih($request, $skemaSektor); - - // ⚠️ K2 — isian hanya diambil bila sebuah versi benar-benar dipilih. - if ($skemaTerpilih !== null) { - // ⚠️ K1: endpoint versi aktif memakai parameter `sektor` (WAJIB), - // dan responsnya sudah memuat `isian[]` + `pemetaan[]`. - $resultSkema = $this->dataspasial->listSkema([ - 'sektor' => $sektorTerpilih['id'], - ]); - - [$isian, $resultDetail] = $this->muatIsian($resultSkema); - } else { - $isian = []; - $resultDetail = null; - } + [$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::fromValue(StatusSkemaEditor::BelumTersedia); - } elseif ($skemaTerpilih === null) { - $statusEditor = $this->editor->statusEditor(null, $skemaSektor); + $statusEditor = StatusSkemaEditor::BelumTersedia(); } else { - $statusEditor = $this->editor->statusEditor($skemaTerpilih, $skemaSektor); + $statusEditor = $this->editor->statusEditor($skemaTerpilih, $daftarSektor); } return view($this->viewName(), [ 'title' => $this->pageTitle(), 'result' => $resultSektor, 'resultSektor' => $resultSektor, - 'resultSkema' => $resultSkema, - 'resultDetail' => $resultDetail, 'daftarSektor' => $daftarSektor, 'sektorTerpilih' => $sektorTerpilih, - 'skemaSektor' => $skemaSektor, 'skemaTerpilih' => $skemaTerpilih, 'isian' => $isian, 'statusEditor' => $statusEditor, - 'daftarSektorSumber' => $daftarSektor, - 'tipeIsian' => TipeIsian::class, ]); } @@ -171,36 +145,70 @@ public function storeSektor(StoreSektorRequest $request): RedirectResponse } /** - * Simpan draf skema (FR-029 … FR-033). + * 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 + public function storeDraf(StoreDrafSkemaRequest $request): RedirectResponse|\Illuminate\Http\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' => $validated['sektor'] ?? null, - 'nomor_versi' => $validated['nomor_versi'] ?? null, - 'catatan_perubahan' => $validated['catatan_perubahan'] ?? null, + 'sektor' => $sektorKode, + 'sektor_nama' => $sektorNama, + 'keterangan' => $validated['keterangan'] ?? null, ]); $result = $this->dataspasial->buatDrafSkema($body); if ($result->isUnavailable()) { Log::warning('DataSpasial: penyimpanan draf gagal', [ - 'sektor' => $validated['sektor'] ?? null, + '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', $result->userMessage()); + ->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' => $validated['sektor'] ?? null, - 'skema' => $result->first()['id'] ?? null, + 'sektor' => $sektorKode, ])) ->with('success', 'Draf skema berhasil disimpan.'); } diff --git a/app/Http/Requests/DataSpasial/StoreDrafSkemaRequest.php b/app/Http/Requests/DataSpasial/StoreDrafSkemaRequest.php index d23f340ef..b25afe916 100644 --- a/app/Http/Requests/DataSpasial/StoreDrafSkemaRequest.php +++ b/app/Http/Requests/DataSpasial/StoreDrafSkemaRequest.php @@ -36,19 +36,21 @@ public function authorize(): bool public function rules(): array { return [ - 'sektor' => ['required', 'string', 'max:100'], - 'catatan_perubahan' => ['nullable', 'string', 'max:500'], - 'nomor_versi' => ['nullable', 'integer', 'min:1'], - - // FR-019: sebuah sektor tidak boleh memiliki nol isian. + // ⚠️ 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 TIDAK lagi menilai "apa yang admin - // ketik" — ia memverifikasi INTEGRITAS nilai turunan itu, karena - // FR-018 tetap mewajibkan kode hasil bangkit memenuhi pola - // snake_case. Bila label tidak menghasilkan slug yang sah, kode - // turunan akan kosong atau diawali angka dan ditolak di sini. + // 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'])], @@ -59,17 +61,18 @@ public function rules(): array 'isian.*.opsi.*' => ['string', 'max:100'], 'isian.*.urutan' => ['required', 'integer', 'min:1'], - // ⚠️ ASUMSI U2 (U3) — penanda tampilan & pemetaan belum ada pada - // kontrak v0.1.0. Field di sini memakai NAMA INTERNAL; pemetaan ke - // nama asumsi kontrak hanya terjadi di `SkemaEditorService` - // (research.md R12). + // ⚠️ 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'], - // ⚠️ VERIFIKASI LANGSUNG: `sumber` adalah ENUM API - // (`openSID`/`dataPresisi`/`impor`), bukan kode sektor. - 'isian.*.sumber' => ['nullable', 'string', Rule::in(['openSID', 'dataPresisi', 'impor'])], - 'isian.*.rujukan' => ['nullable', 'string', 'max:255'], + // Q20 — pemetaan: `sumber` enum, `tabelSumber` + `kolomSumber` + // diisi bebas, wajib bila sumber bukan manual. + 'isian.*.sumber' => ['nullable', 'string', Rule::in(['openSID', 'dataPresisi', 'impor', 'manual'])], + 'isian.*.tabelSumber' => ['nullable', 'string', 'max:100'], + 'isian.*.kolomSumber' => ['nullable', 'string', 'max:100'], ]; } @@ -179,7 +182,7 @@ public function messages(): array '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.required' => 'Sektor wajib dipilih.', + 'sektor_nama.required' => 'Nama sektor wajib diisi.', ]; } diff --git a/app/Http/Requests/DataSpasial/StoreSektorRequest.php b/app/Http/Requests/DataSpasial/StoreSektorRequest.php deleted file mode 100644 index 6f582c6cc..000000000 --- a/app/Http/Requests/DataSpasial/StoreSektorRequest.php +++ /dev/null @@ -1,61 +0,0 @@ - - */ - public function rules(): array - { - return [ - 'nama' => ['required', 'string', 'max:100', 'regex:/^[^\s](.*[^\s])?$/'], - 'ikon' => ['nullable', 'string', 'max:100'], - ]; - } - - /** - * @return array - */ - public function messages(): array - { - return [ - 'nama.required' => 'Nama sektor wajib diisi.', - 'nama.regex' => 'Nama sektor tidak boleh diawali atau diakhiri spasi.', - 'nama.max' => 'Nama sektor maksimal 100 karakter.', - ]; - } - - /** - * @return array - */ - public function attributes(): array - { - return ['nama' => 'nama sektor']; - } -} diff --git a/app/Http/Requests/DataSpasial/UpdateSektorRequest.php b/app/Http/Requests/DataSpasial/UpdateSektorRequest.php deleted file mode 100644 index e5f8ca521..000000000 --- a/app/Http/Requests/DataSpasial/UpdateSektorRequest.php +++ /dev/null @@ -1,59 +0,0 @@ - - */ - public function rules(): array - { - return [ - 'nama' => ['required', 'string', 'max:100', 'regex:/^[^\s](.*[^\s])?$/'], - 'ikon' => ['nullable', 'string', 'max:100'], - ]; - } - - /** - * @return array - */ - public function messages(): array - { - return [ - 'nama.required' => 'Nama sektor wajib diisi.', - 'nama.regex' => 'Nama sektor tidak boleh diawali atau diakhiri spasi.', - 'nama.max' => 'Nama sektor maksimal 100 karakter.', - ]; - } - - /** - * @return array - */ - public function attributes(): array - { - return ['nama' => 'nama sektor']; - } -} diff --git a/app/Services/DataSpasialApiService.php b/app/Services/DataSpasialApiService.php index abc55b542..41ba98676 100644 --- a/app/Services/DataSpasialApiService.php +++ b/app/Services/DataSpasialApiService.php @@ -586,43 +586,4 @@ public function imporNilai(array $body, ?int $schemaVersion = null): DataSpasial { return $this->request('POST', 'impor', [], $body, $schemaVersion); } - - /** - * Buat sektor baru. - * - * ⚠️ **UTANG KONTRAK U1** — endpoint `POST /dataspasial/sektor` **belum ada** - * pada `openapi-fondasi.yaml` v0.1.0 (hanya tersedia `GET /sektor` dan - * `GET /sektor/{id}`). Method ini memanggil endpoint yang belum - * ada dan harus diselaraskan setelah API-03 (#468) terbit. - * Bentuk asumsinya ada di `specs/004-pengelola-skema-editor/contracts/`. - * - * ⚠️ **Kode sektor TIDAK dikirim** — kode dibangkitkan oleh sisi API dan - * dijamin unik global (research.md R4, spec Q5). Bila API resmi justru - * mewajibkan kode, bentuk badan di sini perlu ditinjau. - * - * @param array $body Berisi `nama` dan opsional `ikon`. - */ - public function buatSektor(array $body, ?int $schemaVersion = null): DataSpasialResult - { - return $this->request('POST', 'sektor', [], $body, $schemaVersion); - } - - /** - * Ubah sektor yang sudah ada (FR-007d-4, spec Q10). - * - * ⚠️ **UTANG KONTRAK U5** — `PUT /dataspasial/sektor/{id}` **belum ada** - * pada `openapi-fondasi.yaml` v0.1.0 maupun di API hidup (`404`). Method ini - * memanggil endpoint yang belum ada dan harus diselaraskan setelah API-03 - * (#468) terbit. Bentuk asumsinya ada di - * `specs/004-pengelola-skema-editor/contracts/openapi-fragment.yaml`. - * - * ⚠️ **Kode sektor TIDAK dikirim dan tidak dapat diubah** — kode - * dibangkitkan server dan bersifat baca-saja (FR-007d-5). - * - * @param array $body Berisi `nama` dan opsional `ikon`. - */ - public function ubahSektor(int $id, array $body, ?int $schemaVersion = null): DataSpasialResult - { - return $this->request('PUT', 'sektor/'.$id, [], $body, $schemaVersion); - } } diff --git a/app/Services/SkemaEditorService.php b/app/Services/SkemaEditorService.php index ce23caf38..61abe05a5 100644 --- a/app/Services/SkemaEditorService.php +++ b/app/Services/SkemaEditorService.php @@ -5,6 +5,7 @@ namespace App\Services; use App\Enums\StatusSkemaEditor; +use Illuminate\Support\Str; /** * Orkestrasi editor skema kabupaten (KAB-02). @@ -78,23 +79,9 @@ public function petakanSektor(array $item): array * @param array> $items * @return array> */ - public function petakanDaftarSektor(array $items, array $semuaSkema = []): array + public function petakanDaftarSektor(array $items): array { - return array_map(function (array $item) use ($semuaSkema) { - $sektor = $this->petakanSektor($item); - $skemaSektor = $this->groupSkemaPerSektor($semuaSkema)[$sektor['id']] ?? []; - - $draf = $this->cariSkema($skemaSektor, StatusSkemaEditor::Draf); - - // Bila counter dari API tidak tersedia, hitung dari daftar skema. - $jumlahSkema = $sektor['jumlahSkema'] ?: count($skemaSektor); - - return array_merge($sektor, [ - 'jumlahSkema' => $jumlahSkema, - 'adaDraf' => $draf !== null || ($jumlahSkema > 0 && $sektor['jumlahTerbit'] === 0), - 'nomorVersiDraf' => $draf['nomorVersi'] ?? null, - ]); - }, $items); + return array_map(fn (array $item): array => $this->petakanSektor($item), $items); } /** @@ -255,58 +242,6 @@ public function terapkanPemetaan(array $isian, array $pemetaan): array }, $isian); } - /** - * Kelompokkan daftar skema global per sektor agar panel skema tidak - * memanggil API sekali per sektor (R1). - * - * @param array> $items - * @return array>> - */ - public function groupSkemaPerSektor(array $items): array - { - $perSektor = []; - - foreach ($items as $item) { - $skema = $this->petakanSektorSkema($item); - $perSektor[$skema['sektorId']][] = $skema; - } - - foreach ($perSektor as &$daftar) { - usort($daftar, static fn (array $a, array $b): int => $b['nomorVersi'] <=> $a['nomorVersi']); - } - unset($daftar); - - return $perSektor; - } - - /** - * Petakan satu entri riwayat versi menjadi bentuk daftar skema (Q9). - * - * ⚠️ Bentuk `riwayat versi skema` **datar** — berbeda dari bentuk - * `skema data spasial` yang bersarang di `attributes.versi_skema`. - * - * @param array $item - * @return array - */ - public function petakanRiwayatVersi(array $item): array - { - $attributes = $this->attributes($item); - - $sektorId = (int) ($attributes['sektor_id'] ?? 0); - $id = (int) ($item['id'] ?? $attributes['id'] ?? 0); - - return [ - 'id' => $id, - 'sektorId' => $sektorId, - 'nomorVersi' => (int) ($attributes['nomor_versi'] ?? 0), - 'status' => (string) ($attributes['status'] ?? ''), - 'catatanPerubahan' => $attributes['catatan_perubahan'] ?? null, - 'diterbitkanPada' => $attributes['diterbitkan_pada'] ?? null, - 'diterbitkanOleh' => $attributes['diterbitkan_oleh'] ?? null, - 'jumlahIsian' => 0, - ]; - } - /** * Turunkan `StatusSkemaEditor` dari skema yang dipilih (FR-008a/FR-008b). * @@ -334,6 +269,28 @@ public function statusEditor(?array $skemaTerpilih, array $skemaSektor): StatusS ]); } + /** + * 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. + * + * @param string $nama + * @return string + */ + 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 // ----------------------------------------------------------------------- @@ -361,11 +318,15 @@ public function composeDrafPayload(array $isian, array $meta = []): array 'satuan' => $baris['satuan'] ?? null, 'opsi' => ($baris['tipe'] ?? '') === 'pilihan' ? array_values($baris['opsi'] ?? []) : null, 'urutan' => $urut++, - // Nama RESMI API (verifikasi langsung — lihat §6a.4 K4). - 'tampil_popup' => (bool) ($baris['popupPeta'] ?? false), - 'tampil_filter' => (bool) ($baris['sebagaiFilter'] ?? false), - 'tampil_indikator' => (bool) ($baris['sebagaiIndikator'] ?? false), - // ⚠️ ASUMSI U3 — pemetaan inline di badan draf. + // 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, tabel_sumber, kolom_sumber}` (Q20). + // View memakai nama internal `tabelSumber`/`kolomSumber`. 'pemetaan' => $this->composePemetaan($baris), ]; @@ -373,10 +334,11 @@ public function composeDrafPayload(array $isian, array $meta = []): array }, $isian); return array_merge([ - 'catatan_perubahan' => $meta['catatan_perubahan'] ?? null, - 'nomor_versi' => $meta['nomor_versi'] ?? null, + 'sektor' => $meta['sektor'] ?? null, + 'sektor_nama' => $meta['sektor_nama'] ?? null, + 'keterangan' => $meta['keterangan'] ?? null, 'isian' => $isianApi, - ], $meta['sektor'] !== null ? ['sektor' => $meta['sektor']] : []); + ], []); } /** @@ -391,13 +353,11 @@ private function composePemetaan(array $baris): ?array return null; } - // ⚠️ API menerima pemetaan sebagai ARRAY di dalam tiap isian (verifikasi - // langsung §6a.3) — bukan objek tunggal seperti asumsi lama. + // Pemetaan berupa array `{sumber, tabel_sumber, kolom_sumber}` (Q20). return [[ - 'id' => 0, 'sumber' => (string) $sumber, - 'rujukan_sumber' => $baris['rujukan'] ?? null, - 'aturan_hitung' => $baris['aturanHitung'] ?? null, + 'tabel_sumber' => $baris['tabelSumber'] ?? null, + 'kolom_sumber' => $baris['kolomSumber'] ?? null, ]]; } diff --git a/public/js/dataspasial/schema-editor.js b/public/js/dataspasial/schema-editor.js index d5244d74a..f2d6071b9 100644 --- a/public/js/dataspasial/schema-editor.js +++ b/public/js/dataspasial/schema-editor.js @@ -92,6 +92,80 @@ function barisBaru(urutan) { * * @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; +}; + function bacaDariDom(tbody) { return Array.from(tbody.querySelectorAll('tr[data-testid="baris-isian"], tr.baris-baru')) .map((tr, index) => { @@ -115,13 +189,9 @@ function bacaDariDom(tbody) { sebagaiFilter: Boolean(nilai('.input-filter')?.checked), sebagaiIndikator: Boolean(nilai('.input-indikator')?.checked), sumber: sel?.dataset?.sumber ?? 'manual', - // ⚠️ `dataset` memakai camelCase: `data-aturan-hitung` → - // `dataset.aturanHitung`. Akses `dataset?.aturan-hitung` - // akan diparse sebagai pengurangan (`aturan - hitung`) dan - // melempar ReferenceError setiap kali serialisasi berjalan. - // Belum ada sumber yang menulis atribut ini, jadi default null. - aturanHitung: sel?.dataset?.aturanHitung ?? null, rujukan: sel?.dataset?.rujukan || null, + tabelSumber: sel?.dataset?.tabelSumber ?? null, + kolomSumber: sel?.dataset?.kolomSumber ?? null, }; }); } @@ -263,10 +333,20 @@ function serialisasiKeForm(isian) { if (baris.sumber && baris.sumber !== 'manual') { tambah('sumber', baris.sumber); tambah('rujukan', baris.rujukan || ''); + tambah('tabelSumber', baris.tabelSumber || ''); + tambah('kolomSumber', baris.kolomSumber || ''); } }); } +/** 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). * @@ -444,13 +524,193 @@ export function mountSchemaEditor() { const wadahPratinjau = document.getElementById('wadah-pratinjau'); const template = document.getElementById('template-baris-isian'); - const bolehUbah = tbody.dataset.bolehUbah === '1'; + // 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 adaDrafBerjalan = Boolean(document.querySelector('[data-testid="item-skema"][data-status="draf"]')); 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'); + 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-opsi, ' + + '.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-opsi, ' + + '.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 opsi = tr.querySelector('.input-opsi'); + 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 ?? ''; + } + + if (opsi) { + Array.from(opsi.options).forEach((option) => { + option.selected = (sumber.opsi ?? []).includes(option.value); + }); + } + + if (sumberNilai) { + sumberNilai.dataset.sumber = sumber.sumber ?? 'manual'; + sumberNilai.dataset.tabelSumber = sumber.tabelSumber ?? ''; + sumberNilai.dataset.kolomSumber = sumber.kolomSumber ?? ''; + + 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); + + 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); @@ -468,14 +728,25 @@ export function mountSchemaEditor() { const pesan = 'Ada perubahan yang belum disimpan. Tinggalkan halaman?'; - return window.confirm(pesan); + return swalKonfirmasi(pesan, 'Perubahan belum disimpan'); }; // ----------------------------------------------------------------------- // Tambah isian (US1#13, US2) // ----------------------------------------------------------------------- - document.getElementById('btn-tambah-isian')?.addEventListener('click', () => { + 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; } @@ -491,7 +762,7 @@ export function mountSchemaEditor() { // ----------------------------------------------------------------------- // Hapus isian (FR-014, US2#4/#6) // ----------------------------------------------------------------------- - tbody.addEventListener('click', (event) => { + tbody.addEventListener('click', async (event) => { const tombol = event.target.closest('.btn-hapus-isian'); if (!tombol) { @@ -511,7 +782,7 @@ export function mountSchemaEditor() { ? `Hapus isian "${label}"? Pemetaan sumber nilainya juga akan dilepas.` : `Hapus isian "${label}"?`; - if (!window.confirm(pesan)) { + if (!(await swalKonfirmasi(pesan, 'Hapus isian'))) { return; } @@ -523,15 +794,52 @@ export function mountSchemaEditor() { // ----------------------------------------------------------------------- // Tambah sektor (US1#8) // ----------------------------------------------------------------------- - const formSektor = document.getElementById('form-tambah-sektor'); + // ----------------------------------------------------------------------- + // 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', () => { - formSektor?.classList.toggle('d-none'); - document.getElementById('input-nama-sektor')?.focus(); - }); + 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); - document.getElementById('btn-batal-sektor')?.addEventListener('click', () => { - formSektor?.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(); }); // ----------------------------------------------------------------------- @@ -630,20 +938,20 @@ export function mountSchemaEditor() { }); if (respons.status === 422) { - window.alert('Sektor ini sudah memiliki draf berjalan.'); + await swalPeringatan('Sektor ini sudah memiliki draf berjalan.'); return; } if (!respons.ok) { - window.alert('Data sementara tidak tersedia. Silakan coba lagi nanti.'); + await swalGalat('Data sementara tidak tersedia. Silakan coba lagi nanti.'); return; } window.location.href = `${window.location.pathname}?sektor=${encodeURIComponent(sektorKode)}`; } catch (error) { - window.alert('Data sementara tidak tersedia. Silakan coba lagi nanti.'); + await swalGalat('Data sementara tidak tersedia. Silakan coba lagi nanti.'); } }); @@ -655,6 +963,13 @@ export function mountSchemaEditor() { return; } + // FR-008f — mode "sektor baru": nama sektor wajib diisi sebelum simpan. + if (modeSektorBaru && ((inputNamaSektor?.value ?? '').trim() === '')) { + await swalPeringatan('Nama sektor wajib diisi.'); + + return; + } + // FR-007i: bila admin SEDANG menyunting draf itu sendiri, konfirmasi // tidak boleh muncul. const menyuntingDrafIni = Boolean(skemaAktif) @@ -669,7 +984,7 @@ export function mountSchemaEditor() { const isian = bacaDariDom(tbody); if (isian.length === 0) { - window.alert('Sektor harus memiliki minimal satu isian.'); + await swalPeringatan('Sektor harus memiliki minimal satu isian.'); return; } @@ -681,7 +996,7 @@ export function mountSchemaEditor() { .filter((label, i, arr) => label !== '' && arr.indexOf(label) !== i); if (labelBentrok.length > 0) { - window.alert(`Label "${labelBentrok[0]}" dipakai lebih dari satu isian pada versi ini.`); + await swalPeringatan(`Label "${labelBentrok[0]}" dipakai lebih dari satu isian pada versi ini.`); return; } @@ -689,7 +1004,7 @@ export function mountSchemaEditor() { const kodeKosong = isian.find((b) => b.kode === ''); if (kodeKosong) { - window.alert(`Label "${kodeKosong.label}" tidak menghasilkan kode yang sah. Perbaiki labelnya.`); + await swalPeringatan(`Label "${kodeKosong.label}" tidak menghasilkan kode yang sah. Perbaiki labelnya.`); return; } @@ -697,7 +1012,60 @@ export function mountSchemaEditor() { serialisasiKeForm(isian); dirty = false; perbaruiTombolTerbit(); - document.getElementById('form-draf')?.submit(); + + // 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(); + } }); // ----------------------------------------------------------------------- @@ -720,6 +1088,15 @@ export function mountSchemaEditor() { 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.'; @@ -734,61 +1111,105 @@ export function mountSchemaEditor() { // Panggil sekali setelah DOM siap agar tombol sesuai keadaan awal. perbaruiTombolTerbit(); - tombolTerbit?.addEventListener('click', () => { + tombolTerbit?.addEventListener('click', async () => { if (!formTerbit || !sektorKode) { return; } // FR-045 — pengaman kedua di sisi klien. if (dirty) { - window.alert('Simpan draf dulu. Ada perubahan yang belum disimpan.'); + await swalPeringatan('Simpan draf dulu. Ada perubahan yang belum disimpan.'); return; } - const item = skemaAktif - ? document.querySelector(`[data-testid="item-skema"][data-skema-id="${skemaAktif}"]`) - : null; - const namaSektor = document.querySelector('[data-testid="item-sektor"][data-sektor-kode="' + sektorKode + '"]') - ?.dataset?.sektorNama || sektorKode; - const nomorVersi = item?.dataset?.nomorVersi || '?'; + 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 — konfirmasi menyebut sektor & nomor versi secara eksplisit. - const pesan = `Terbitkan versi ${nomorVersi} pada sektor "${namaSektor}"?\n\n` - + 'Setelah diterbitkan, versi ini menjadi immutable dan tidak dapat diubah. ' - + 'Desa yang sudah memakai versi lama tidak ikut berubah.'; + // 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 (!window.confirm(pesan)) { + 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 = window.prompt('Catatan perubahan (opsional)', '') || ''; + inputCatatan.value = catatan; } - formTerbit.submit(); + // 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' }); + }); }); // ----------------------------------------------------------------------- - // Pemetaan isian ke sumber = daftar sektor (US4, FR-022/FR-023/FR-024) + // Modal pemilihan sumber nilai (US4, FR-022 … FR-025, Q20) + // + // ⚠️ Q20 — pemetaan memakai `{sumber, tabel_sumber, kolom_sumber}` dan + // TIDAK memakai katalog/daftar sektor. Pilihan presented的是 dua mode: + // **Manual** (isian diisi operator desa) atau **Otomatis** dari sumber data. + // Modal **tidak terikat** pada pemetaan yang sudah tersimpan — isian + // selalu dibuka kosong agar admin bebas memilih ulang. // ----------------------------------------------------------------------- - const modal = document.getElementById('modal-pilih-sumber'); - let barisDipilih = null; + const SUMBER_ENUM = ['openSID', 'dataPresisi', 'impor']; - const tulisPemetaan = (tr, sumber, nama) => { + /** 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; + sel.dataset.sumber = sumber || 'manual'; + sel.dataset.tabelSumber = tabel || ''; + sel.dataset.kolomSumber = kolom || ''; sel.dataset.rujukan = ''; const tombol = sel.querySelector('.btn-pilih-sumber-baris'); - const badge = tombol?.querySelector('.badge') - ?? sel.querySelector('.badge'); + const badge = tombol?.querySelector('.badge') ?? sel.querySelector('.badge'); if (!badge) { return; @@ -796,86 +1217,135 @@ export function mountSchemaEditor() { if (sumber && sumber !== 'manual') { badge.className = 'badge badge-info'; - badge.innerHTML = '' + nama; + badge.innerHTML = '' + sumber; } else { badge.className = 'badge badge-secondary'; badge.textContent = 'Manual'; } - }; - tbody.addEventListener('click', (event) => { - const tombol = event.target.closest('.btn-pilih-sumber-baris'); + // 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 (!tombol || !modal) { - return; + if (inputTabel) { + inputTabel.value = tabel || ''; } - barisDipilih = tombol.closest('tr'); + if (inputKolom) { + inputKolom.value = kolom || ''; + } + }; - const sel = barisDipilih.querySelector('.sumber-nilai'); - const label = document.getElementById('sumber-kode-isian'); + /** 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.'); - if (label) { - label.textContent = barisDipilih.querySelector('.input-label')?.value || '(tanpa label)'; + return; } - const sudahAda = sel?.dataset.sumber && sel.dataset.sumber !== 'manual'; - const tombolLepas = document.getElementById('btn-lepas-pemetaan'); + const label = tr.querySelector('.input-label')?.value || '(tanpa label)'; + + // ⚠️ Modal TIDAK diisi dari pemetaan yang sudah ada — selalu mulai + // kosong agar admin bebas memilih (bukan terikat pada nilai lama). + 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: '' }; + } - if (tombolLepas) { - tombolLepas.classList.toggle('d-none', !sudahAda); - } + const sumber = document.getElementById('sumber-pilihan')?.value || ''; + const tabel = (document.getElementById('sumber-tabel')?.value || '').trim(); + const kolom = (document.getElementById('sumber-kolom')?.value || '').trim(); - const cari = document.getElementById('cari-sumber'); + // 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.'); - if (cari) { - cari.value = ''; - } + return false; + } - document.querySelectorAll('#daftar-sumber .item-sumber').forEach((item) => { - item.style.display = ''; + 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' }, }); - // eslint-disable-next-line no-undef - window.jQuery?.(modal).modal('show'); - }); + if (!hasil.isConfirmed || !hasil.value) { + return; + } - // Pencarian sederhana pada daftar sektor. - document.getElementById('cari-sumber')?.addEventListener('input', (event) => { - const kunci = event.target.value.toLowerCase(); + tulisPemetaan(tr, hasil.value); + mutasi(); + }; - document.querySelectorAll('#daftar-sumber .item-sumber').forEach((item) => { - const nama = (item.dataset.sektor || '').toLowerCase(); - const kode = (item.dataset.kode || '').toLowerCase(); + tbody.addEventListener('click', (event) => { + const tombol = event.target.closest('.btn-pilih-sumber-baris'); - item.style.display = (nama.includes(kunci) || kode.includes(kunci)) ? '' : 'none'; - }); - }); + if (!tombol) { + return; + } - document.querySelectorAll('#daftar-sumber .btn-pilih-sumber')?.forEach((tombol) => { - tombol.addEventListener('click', () => { - if (!barisDipilih) { - return; - } + const tr = tombol.closest('tr'); - tulisPemetaan(barisDipilih, tombol.dataset.kode, tombol.dataset.nama); - mutasi(); + if (!tr) { + return; + } - // eslint-disable-next-line no-undef - window.jQuery?.(modal).modal('hide'); - }); - }); + if (!bolehUbah) { + swalPeringatan('Skema ini sedang dibaca. Tekan Ubah untuk menyuntingnya.'); - document.getElementById('btn-lepas-pemetaan')?.addEventListener('click', () => { - if (!barisDipilih) { return; } - tulisPemetaan(barisDipilih, 'manual', null); - mutasi(); - - // eslint-disable-next-line no-undef - window.jQuery?.(modal).modal('hide'); + bukaModalSumberNilai(tr); }); // ----------------------------------------------------------------------- diff --git a/resources/views/dataspasial/skema/index.blade.php b/resources/views/dataspasial/skema/index.blade.php index b6e9e34b1..02c69c5d0 100644 --- a/resources/views/dataspasial/skema/index.blade.php +++ b/resources/views/dataspasial/skema/index.blade.php @@ -10,11 +10,12 @@ {{-- Editor skema kabupaten — KAB-02 (#1297). - Tiga panel (FR-008): - kiri : daftar sektor + daftar skema (skema hanya muncul setelah - sektor dipilih — FR-007a) - tengah : tabel isian - kanan : pratinjau formulir desa + Dua panel (FR-008, Q22): + kiri : daftar sektor + tengah : tabel isian + pratinjau formulir desa + + Tidak ada lagi daftar skema maupun nomor versi di UI — daftar isian + selalu berasal dari skema terakhir sektor yang dipilih. Seluruh area dibungkus SATU `` agar tiga keadaan KAB-01 (data / kosong / tidak tersedia) tetap berlaku tanpa logika @@ -30,12 +31,12 @@ $bolehSimpan = $statusEditor->bolehSimpan(); $statusNilai = $statusEditor->value; $sektorKode = $sektorTerpilih['kode'] ?? null; - $skemaId = $skemaTerpilih['id'] ?? null; + $sektorNama = $sektorTerpilih['nama'] ?? ''; @endphp
+ data-sektor-nama="{{ $sektorNama }}"> {{-- Error validasi per baris isian (FR-017). --}} @if ($errors->any()) @@ -50,36 +51,24 @@ @endif
- {{-- ============ PANEL KIRI: sektor & skema ============ --}} + {{-- ============ PANEL KIRI: sektor ============ --}}
@include('dataspasial.skema.partials.panel-sektor', [ 'daftarSektor' => $daftarSektor, 'sektorTerpilih' => $sektorTerpilih, 'bolehUbah' => $bolehUbah, ]) - - {{-- - Daftar skema: HANYA tampil setelah sektor dipilih (FR-007a). - Sengaja berada di panel kiri yang sama dengan daftar sektor. - --}} - @include('dataspasial.skema.partials.panel-skema', [ - 'skemaSektor' => $skemaSektor, - 'skemaTerpilih' => $skemaTerpilih, - 'sektorTerpilih' => $sektorTerpilih, - ])
{{-- ============ PANEL TENGAH: editor isian ============ --}}
@include('dataspasial.skema.partials.tabel-isian', [ 'isian' => $isian, - 'skemaTerpilih' => $skemaTerpilih, 'sektorTerpilih' => $sektorTerpilih, 'statusEditor' => $statusEditor, 'bolehUbah' => $bolehUbah, 'bolehSimpan' => $bolehSimpan, 'bolehTerbitkan' => $statusEditor->bolehSimpan(), - 'daftarSektorSumber' => $daftarSektorSumber, ])
@@ -105,5 +94,14 @@ `package.json` — masalah lama yang TIDAK terkait fitur ini dan tidak diselesaikan di sini (Prinsip V: jangan menambah dependensi). --}} + {{-- + FR-008g/Q27 — SweetAlert2 untuk seluruh pesan pada halaman editor skema. + + ⚠️ Dimuat via CDN (pola yang sama dengan + `resources/views/penduduk/index.blade.php:95`) karena bundle Vite yang + sedang ter-build di `public/build/assets/app-*.js` **belum memuat** + `sweetalert2`, sehingga `window.Swal` belum tersedia dari bundle. + --}} + @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 index 2f37d4d46..e75eaa18c 100644 --- a/resources/views/dataspasial/skema/partials/form-isian.blade.php +++ b/resources/views/dataspasial/skema/partials/form-isian.blade.php @@ -92,6 +92,8 @@ --}} @if ($bolehUbah)
- {{-- US7 (FR-007d-4): ubah nama & ikon sektor yang sudah ada. --}} - + {{-- US7 (FR-007d-4): ubah nama & ikon sektor sekarang dilakukan + melalui editor skema (nama sektor dikirim sebagai sektor_nama + pada setiap simpan), bukan tombol tersendiri di sini. --}} @endforeach @@ -79,99 +74,3 @@ class="btn btn-tool {{ $terpilih ? 'btn-light' : 'btn-outline-secondary' }} btn-
- -{{-- - Formulir penambahan sektor. - - ⚠️ HANYA memuat kolom NAMA (spec Q5). Tidak ada kolom kode: kode dibangkitkan - API. ⚠️ Keunikan nama diperiksa API (spec Q13), bukan di OpenKab. ---}} -
- @csrf - -
-
-

Sektor Baru

-
-
-
- - - @error('nama') -
{{ $message }}
- @enderror -
- -
- - -
- -
- - -
-
-
-
- -{{-- - Formulir ubah sektor (US7, FR-007d-4 … FR-007d-6). - - ⚠️ **KODE SEKTOR TIDAK ADA DI SINI** — kode dibangkitkan server dan - bersifat baca-saja (FR-007d-5). Kode hanya ditampilkan sebagai informasi. - Badannya memuat `nama` + `ikon` sesuai `PUT /dataspasial/sektor/{id}`. - - ⚠️ Tidak ada `nama_sektor_ada` lagi — keunikan nama adalah tanggung jawab API - (spec Q13), sehingga OpenKab tidak perlu mengirim daftar sektor. ---}} -
- @csrf - @method('PUT') - -
-
-

Ubah Sektor

-
-
-
- -

- - Kode tidak dapat diubah. -

-
- -
- - - @error('nama') -
{{ $message }}
- @enderror -
- -
- - -
- -
- - -
-
-
-
\ No newline at end of file diff --git a/resources/views/dataspasial/skema/partials/panel-skema.blade.php b/resources/views/dataspasial/skema/partials/panel-skema.blade.php deleted file mode 100644 index 5f6b31614..000000000 --- a/resources/views/dataspasial/skema/partials/panel-skema.blade.php +++ /dev/null @@ -1,78 +0,0 @@ -{{-- - Panel kiri (bagian bawah): daftar skema untuk sektor yang dipilih. - - ⚠️ FR-007a — daftar skema HANYA dirender bila admin SUDAH memilih sektor. - Sebelum itu, panel menampilkan penjelasan, bukan daftar kosong yang membingungkan. ---}} -
-
-

- - Skema - @if ($sektorTerpilih) - · {{ $sektorTerpilih['nama'] }} - @endif -

-
- -
- @if ($sektorTerpilih === null) - {{-- Belum ada sektor dipilih — daftar skema belum boleh tampil (FR-007a). --}} -

- Pilih salah satu sektor di atas untuk melihat daftar skema versi dan drafnya. -

- @elseif (count($skemaSektor) === 0) -

- Sektor ini belum memiliki skema. Buat draf pertama untuk mulai menyusun isian. -

- @else - - @endif -
- - @if ($sektorTerpilih !== null) - {{-- - Tombol "+ Tambah Skema Baru" — DI BAWAH daftar skema (FR-007c). - - Sifatnya `type="button"`: sekali diklik, JS memeriksa apakah draf sudah - berjalan. Bila ada, tampil konfirmasi "Buka draf / Batal" (FR-007g/Q6); - bila belum, POST draf kosong ke `POST /dataspasial/skema`. - --}} - - @endif -
\ No newline at end of file diff --git a/resources/views/dataspasial/skema/partials/pilih-sumber.blade.php b/resources/views/dataspasial/skema/partials/pilih-sumber.blade.php deleted file mode 100644 index 63d540bfc..000000000 --- a/resources/views/dataspasial/skema/partials/pilih-sumber.blade.php +++ /dev/null @@ -1,69 +0,0 @@ -{{-- - Pemilih sumber nilai untuk pemetaan isian (FR-022/FR-023). - - ⚠️ VERIFIKASI LANGSUNG KE API (§6a.5): `pemetaan[].sumber` adalah **ENUM** - `openSID` / `dataPresisi` / `impor` — BUKAN kode sektor. Keputusan Q2 ("sumber - = daftar sektor") TIDAK sesuai implementasi dan dicabut; daftar di bawah - memakai enum resmi tersebut. - - Isian yang sudah terpetakan ditampilkan sebagai badge pada tabel isian, - bukan sebagai select — melepaskannya dilakukan lewat tombol di sini. ---}} - \ No newline at end of file diff --git a/resources/views/dataspasial/skema/partials/pratinjau.blade.php b/resources/views/dataspasial/skema/partials/pratinjau.blade.php index 7e71f3303..191da64b9 100644 --- a/resources/views/dataspasial/skema/partials/pratinjau.blade.php +++ b/resources/views/dataspasial/skema/partials/pratinjau.blade.php @@ -23,16 +23,10 @@
Sektor
{{ $sektorTerpilih['nama'] }}
- @if ($skemaTerpilih) - - Versi {{ $skemaTerpilih['nomorVersi'] }} - @if ($skemaTerpilih['status'] === 'terbit') - Terbit - @else - Draf - @endif - - @endif + + Isian selalu dimuat dari skema terakhir sektor ini + (draf bila ada, terbit baca-saja bila tidak). +

diff --git a/resources/views/dataspasial/skema/partials/tabel-isian.blade.php b/resources/views/dataspasial/skema/partials/tabel-isian.blade.php index 29d984caf..76fe3052c 100644 --- a/resources/views/dataspasial/skema/partials/tabel-isian.blade.php +++ b/resources/views/dataspasial/skema/partials/tabel-isian.blade.php @@ -13,15 +13,35 @@ saat story berikutnya tiba (FR-008). --}}
-
+

Isian @if ($sektorTerpilih) - · {{ $sektorTerpilih['nama'] }} + · {{ $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). + --}} + @if ($sektorTerpilih) +
+ + + @error('sektor_nama') +
{{ $message }}
+ @enderror +
+ @endif + +
{{-- Simpan draf: aktif hanya bila skema berstatus draf (FR-008b). --}} + + {{-- + 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 ($skemaTerpilih !== null && $skemaTerpilih['status'] === 'terbit') + @if ($statusEditor->value === 'terbit') {{-- Skema terbit hanya baca-saja (FR-008a). --}}