diff --git a/.env.example b/.env.example index 78ccc0d3..393f4ed1 100644 --- a/.env.example +++ b/.env.example @@ -80,6 +80,13 @@ LONGITUDE_MAP = 115.046600 API_DATABASE_GABUNGAN_HOST=http://127.0.0.1:8001 SANCTUM_STATEFUL_DOMAINS=localhost +# DataSpasial - API Satu Data (App\Services\DataSpasialApiService) +# Base URL & token mengikuti pengaturan database gabungan (API_DATABASE_GABUNGAN_HOST +# dan Setting `database_gabungan_api_key`); TIDAK ada env khusus untuk keduanya. +# Batas waktu permintaan (detik) & masa cache baca (detik, 0 = nonaktif). +API_DATASPASIAL_TIMEOUT=10 +API_DATASPASIAL_CACHE_TTL=300 + # OTP Configuration OTP_TOKEN_EXPIRES_MINUTES=5 OTP_MAX_VERIFICATION_ATTEMPTS=3 diff --git a/app/Enums/DataSpasialReason.php b/app/Enums/DataSpasialReason.php new file mode 100644 index 00000000..d141835f --- /dev/null +++ b/app/Enums/DataSpasialReason.php @@ -0,0 +1,78 @@ + 'DataSpasial', + 'icon' => 'fas fa-map', + 'permission' => 'dataspasial', + 'submenu' => [ + [ + 'icon' => 'far fa-fw fa-circle', + 'text' => 'Skema DataSpasial', + 'url' => 'dataspasial/skema', + 'permission' => 'dataspasial-skema', + ], + [ + 'icon' => 'far fa-fw fa-circle', + 'text' => 'Peta Gabungan', + 'url' => 'dataspasial/peta', + 'permission' => 'dataspasial-peta', + ], + [ + 'icon' => 'far fa-fw fa-circle', + 'text' => 'Dasbor DataSpasial', + 'url' => 'dataspasial/dasbor', + 'permission' => 'dataspasial-dasbor', + ], + [ + 'icon' => 'far fa-fw fa-circle', + 'text' => 'Impor Data', + 'url' => 'dataspasial/impor', + 'permission' => 'dataspasial-impor', + ], + [ + 'icon' => 'far fa-fw fa-circle', + 'text' => 'Monitoring', + 'url' => 'dataspasial/monitoring', + 'permission' => 'dataspasial-monitoring', + ], + ], + ], [ 'text' => 'SOTK', 'icon' => 'fa fa-tags', diff --git a/app/Exceptions/DataSpasialUnavailableException.php b/app/Exceptions/DataSpasialUnavailableException.php new file mode 100644 index 00000000..aadd57a0 --- /dev/null +++ b/app/Exceptions/DataSpasialUnavailableException.php @@ -0,0 +1,42 @@ +value, + 0, + $previous + ); + } + + /** + * Pesan yang aman ditampilkan ke pengguna akhir (FR-014). + */ + public function userMessage(): string + { + return DataSpasialReason::userMessage(); + } +} diff --git a/app/Http/Controllers/DataSpasial/DasborDataSpasialController.php b/app/Http/Controllers/DataSpasial/DasborDataSpasialController.php new file mode 100644 index 00000000..ad65c658 --- /dev/null +++ b/app/Http/Controllers/DataSpasial/DasborDataSpasialController.php @@ -0,0 +1,38 @@ +dataspasial->ringkasan($request->only([ + 'sektor_id', + 'periode', + 'kode_kabupaten', + 'kode_kecamatan', + 'kode_desa', + ])); + } +} diff --git a/app/Http/Controllers/DataSpasial/DataSpasialController.php b/app/Http/Controllers/DataSpasial/DataSpasialController.php new file mode 100644 index 00000000..5fb1565f --- /dev/null +++ b/app/Http/Controllers/DataSpasial/DataSpasialController.php @@ -0,0 +1,51 @@ + $filters + */ + abstract protected function fetch(Request $request): DataSpasialResult; + + public function index(Request $request): View + { + return view($this->viewName(), [ + 'title' => $this->pageTitle(), + 'result' => $this->fetch($request), + ]); + } +} diff --git a/app/Http/Controllers/DataSpasial/ImporDataController.php b/app/Http/Controllers/DataSpasial/ImporDataController.php new file mode 100644 index 00000000..0b566665 --- /dev/null +++ b/app/Http/Controllers/DataSpasial/ImporDataController.php @@ -0,0 +1,36 @@ +dataspasial->listFasilitas($request->only([ + 'sektor_id', + 'skema_versi_id', + 'kode_desa', + ])); + } +} diff --git a/app/Http/Controllers/DataSpasial/MonitoringController.php b/app/Http/Controllers/DataSpasial/MonitoringController.php new file mode 100644 index 00000000..cbba0982 --- /dev/null +++ b/app/Http/Controllers/DataSpasial/MonitoringController.php @@ -0,0 +1,39 @@ +dataspasial->adopsi($request->only([ + 'sektor_id', + 'skema_versi_id', + 'status_adopsi', + 'kode_kabupaten', + 'kode_kecamatan', + 'kode_desa', + ])); + } +} diff --git a/app/Http/Controllers/DataSpasial/PetaGabunganController.php b/app/Http/Controllers/DataSpasial/PetaGabunganController.php new file mode 100644 index 00000000..58998df9 --- /dev/null +++ b/app/Http/Controllers/DataSpasial/PetaGabunganController.php @@ -0,0 +1,37 @@ +dataspasial->geojsonGabungan($request->only([ + 'sektor_id', + 'kode_kabupaten', + 'kode_kecamatan', + 'kode_desa', + ])); + } +} diff --git a/app/Http/Controllers/DataSpasial/SkemaDataSpasialController.php b/app/Http/Controllers/DataSpasial/SkemaDataSpasialController.php new file mode 100644 index 00000000..3fdbdcf5 --- /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 86b70fc1..fcbf0580 100644 --- a/app/Services/BaseApiService.php +++ b/app/Services/BaseApiService.php @@ -24,7 +24,10 @@ public function __construct() $this->header = [ 'Accept' => 'application/ld+json', 'Content-Type' => 'application/json', - 'Authorization' => 'Bearer '.$this->settings['database_gabungan_api_key'] ?? '', + // Catatan: pada PHP, '.' mengikat lebih kuat daripada '??', sehingga + // 'Bearer '.$x['k'] ?? '' tetap menghitung akses array tanpa + // perlindungan. Tanda kurung diperlukan. + 'Authorization' => 'Bearer '.($this->settings['database_gabungan_api_key'] ?? ''), ]; $this->baseUrl = config('app.databaseGabunganUrl'); $this->kodeKecamatan = str_replace('.', '', config('profil.kecamatan_id')); diff --git a/app/Services/DataSpasialApiService.php b/app/Services/DataSpasialApiService.php new file mode 100644 index 00000000..d50a9bbb --- /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 00000000..e61b77c7 --- /dev/null +++ b/app/Services/DataSpasialResult.php @@ -0,0 +1,204 @@ +> $data Item hasil unwrap dari envelope API + * @param array $meta Berisi kunci `pagination` dan `links` bila ada + */ + private function __construct( + public readonly DataSpasialStatus $status, + public readonly array $data, + public readonly array $meta, + public readonly ?DataSpasialReason $reason, + public readonly int $httpStatus, + public readonly ?string $message, + ) { + $this->assertInvariants(); + } + + /** + * Respons sukses berisi minimal satu item. + * + * @param array> $data + * @param array $meta + */ + public static function data(array $data, array $meta = [], ?DataSpasialReason $reason = null, int $httpStatus = 200): self + { + return new self(DataSpasialStatus::fromValue(DataSpasialStatus::Data), $data, $meta, $reason, $httpStatus, null); + } + + /** + * Respons sukses tanpa data (FR-015). + * + * @param array $meta + */ + public static function empty(array $meta = [], ?DataSpasialReason $reason = null, int $httpStatus = 200): self + { + return new self(DataSpasialStatus::fromValue(DataSpasialStatus::Empty), [], $meta, $reason, $httpStatus, null); + } + + /** + * Data tidak dapat diperoleh sementara (FR-014). + * + * @param array $meta + */ + public static function unavailable( + DataSpasialReason $reason, + int $httpStatus = 0, + ?string $message = null, + array $meta = [] + ): self { + return new self(DataSpasialStatus::fromValue(DataSpasialStatus::Unavailable), [], $meta, $reason, $httpStatus, $message); + } + + public function isData(): bool + { + return $this->status->is(DataSpasialStatus::Data); + } + + public function isEmpty(): bool + { + return $this->status->is(DataSpasialStatus::Empty); + } + + public function isUnavailable(): bool + { + return $this->status->is(DataSpasialStatus::Unavailable); + } + + /** + * @return array|null + */ + public function first(): ?array + { + return $this->data[0] ?? null; + } + + public function count(): int + { + return count($this->data); + } + + /** + * @return array + */ + public function pagination(): array + { + return $this->meta['pagination'] ?? []; + } + + /** + * @return array + */ + public function links(): array + { + return $this->meta['links'] ?? []; + } + + /** + * Pesan generik untuk pengguna akhir. Tidak pernah menyertakan pesan + * teknis dari API (FR-014/FR-016). + */ + public function userMessage(): string + { + return DataSpasialReason::userMessage(); + } + + /** + * Bentuk serializable untuk disimpan pada cache. + * + * @return array + */ + public function toCachePayload(): array + { + return [ + 'status' => $this->status->value, + 'data' => $this->data, + 'meta' => $this->meta, + 'http_status' => $this->httpStatus, + ]; + } + + /** + * Bangun kembali hasil dari cache. `reason` & `message` sengaja tidak + * disimpan karena hasil `unavailable` tidak pernah masuk cache. + * + * @param array $payload + */ + public static function fromCachePayload(array $payload): self + { + $data = is_array($payload['data'] ?? null) ? $payload['data'] : []; + $meta = is_array($payload['meta'] ?? null) ? $payload['meta'] : []; + $httpStatus = (int) ($payload['http_status'] ?? 200); + + return $data === [] + ? self::empty($meta, null, $httpStatus) + : self::data($data, $meta, null, $httpStatus); + } + + /** + * Konteks teknis untuk log backend. WAJIB tidak dirender ke view. + * + * @return array + */ + public function logContext(string $endpoint = ''): array + { + return array_filter([ + 'endpoint' => $endpoint !== '' ? $endpoint : null, + 'status' => $this->status->value, + 'reason' => $this->reason?->value, + 'http_status' => $this->httpStatus, + 'message' => $this->message, + ], static fn ($value) => $value !== null); + } + + /** + * Jaga invariant object agar tidak dapat dibangun salah oleh pemanggil. + */ + private function assertInvariants(): void + { + if ($this->status->is(DataSpasialStatus::Data)) { + if ($this->data === []) { + throw new InvalidArgumentException( + 'DataSpasialResult berstatus data wajib memiliki minimal satu item.' + ); + } + + if ($this->reason !== null) { + throw new InvalidArgumentException( + 'DataSpasialResult berstatus data tidak boleh memiliki reason.' + ); + } + } + + if ($this->status->is(DataSpasialStatus::Empty) && $this->reason !== null) { + throw new InvalidArgumentException( + 'DataSpasialResult berstatus empty tidak boleh memiliki reason.' + ); + } + + if ($this->status->is(DataSpasialStatus::Unavailable) && $this->reason === null) { + throw new InvalidArgumentException( + 'DataSpasialResult berstatus unavailable wajib memiliki reason.' + ); + } + } +} diff --git a/config/dataspasial.php b/config/dataspasial.php new file mode 100644 index 00000000..45b605b2 --- /dev/null +++ b/config/dataspasial.php @@ -0,0 +1,61 @@ + (int) env('API_DATASPASIAL_TIMEOUT', 10), + + /* + | Masa berlaku cache operasi baca dalam detik. 0 berarti cache nonaktif. + */ + 'cache_ttl' => (int) env('API_DATASPASIAL_CACHE_TTL', 300), + + /* + | Store cache yang dipakai. null berarti memakai default aplikasi. + */ + 'cache_store' => env('API_DATASPASIAL_CACHE_STORE'), + + /* + | Prefix kunci cache operasi baca. Memungkinkan invalidasi seluruh cache + | baca tanpa menyentuh cache modul lain. + */ + 'cache_prefix' => 'dataspasial:get:', + + /* + | Header identitas pemanggil yang dibaca API untuk audit (API-07). + */ + 'user_id_header' => 'X-OpenKab-User-Id', + + 'user_name_header' => 'X-OpenKab-User-Name', + + /* + | Prefix path endpoint DataSpasial di sisi API. + */ + 'path_prefix' => '/api/v1/dataspasial', + + /* + | Pesan yang ditampilkan ke pengguna saat data tidak dapat diperoleh. + | WAJIB generik — detail teknis hanya untuk log backend. + */ + 'unavailable_message' => 'Data sementara tidak tersedia. Silakan coba lagi nanti.', + +]; diff --git a/database/migrations/2026_10_14_000001_create_dataspasial_roles.php b/database/migrations/2026_10_14_000001_create_dataspasial_roles.php new file mode 100644 index 00000000..fb8a4207 --- /dev/null +++ b/database/migrations/2026_10_14_000001_create_dataspasial_roles.php @@ -0,0 +1,298 @@ + izin menu (research.md R6). + * + * `read` dipakai untuk akses tampilan; `write`/`edit`/`delete` hanya + * diberikan kepada peran yang memang mengelola. + * + * @var array>> + */ + private const ROLE_PERMISSIONS = [ + 'admin skema' => [ + 'menu' => ['skema', 'peta', 'dasbor', 'monitoring'], + 'abilities' => [ + 'skema' => ['read', 'write', 'edit', 'delete'], + 'peta' => ['read'], + 'dasbor' => ['read'], + 'monitoring' => ['read'], + ], + ], + + 'operator impor' => [ + 'menu' => ['impor', 'peta', 'dasbor', 'monitoring'], + 'abilities' => [ + 'impor' => ['read', 'write'], + 'peta' => ['read'], + 'dasbor' => ['read'], + 'monitoring' => ['read'], + ], + ], + + 'analis opd' => [ + 'menu' => ['peta', 'dasbor', 'monitoring'], + 'abilities' => [ + 'peta' => ['read'], + 'dasbor' => ['read'], + 'monitoring' => ['read'], + ], + ], + ]; + + /** + * Permission grup induk — selalu diberikan agar menu utama tampil. + */ + private const PARENT_PERMISSION = 'dataspasial'; + + /** + * Sediakan peran DataSpasial beserta izin menunya (FR-010/FR-012). + * + * Di OpenKab, "peran" dimodelkan sebagai Team (grup) + Role spatie dengan + * `team_id` yang sama — mengikuti pola `2023_06_13_073258_create_role.php` + * dan `App\Console\Commands\updateAdminMenu` (research.md R5). + * + * Dijalankan sebagai data migration agar otomatis tersedia saat deploy + * (`php artisan migrate`). IDEMPOTEN: `Team::firstOrCreate`, + * `Role::firstOrCreate`, dan `Permission::findOrCreate`. + */ + public function up(): void + { + $this->ensureAdministratorAccess(); + + foreach (self::ROLE_PERMISSIONS as $teamName => $config) { + $this->ensureRole($teamName, $config); + } + } + + /** + * Cabut peran & izin DataSpasial saat rollback. + */ + public function down(): void + { + foreach (array_keys(self::ROLE_PERMISSIONS) as $teamName) { + $team = Team::where('name', $teamName)->first(); + + if (! $team) { + continue; + } + + setPermissionsTeamId($team->id); + + Role::where('team_id', $team->id)->where('name', $teamName)->delete(); + + $team->users()->detach(); + $team->delete(); + } + + setPermissionsTeamId(null); + + Permission::where('name', 'like', 'dataspasial%')->delete(); + Artisan::call('admin:menu-update'); + } + + /** + * Beri role `administrator` akses ke menu DataSpasial. + * + * Cukup panggil `admin:menu-update`: command tersebut membaca grup menu + * DataSpasial dari `App\Enums\Modul::Menu` (wajib sudah terdaftar di sana), + * menuliskan menu ke team `administrator`, dan menyinkronkan seluruh + * permission turunannya (`dataspasial-*`) ke role administrator. + * + * Dijaga bila team administrator belum ada (mis. basis data sangat awal). + */ + private function ensureAdministratorAccess(): void + { + if (! Team::where('name', 'administrator')->exists()) { + return; + } + + Artisan::call('admin:menu-update'); + } + + /** + * @param array{menu: array, abilities: array>} $config + */ + private function ensureRole(string $teamName, array $config): void + { + $menu = $this->buildMenu($config['menu'], $config['abilities']); + + $team = Team::firstOrCreate( + ['name' => $teamName], + ['menu' => $menu] + ); + + // Menu sudah ada tetapi belum dalam bentuk yang benar (mis. dibuat + // sebelum penambahan flag ability, atau belum berisi submenu + // DataSpasial) → perbaiki agar hak akses tampil tercentang di UI dan + // tidak terhapus saat grup disimpan. Menu yang sudah benar tidak diubah. + if ($this->needsMenuRepair($team->menu)) { + $team->update(['menu' => $menu]); + } + + setPermissionsTeamId($team->id); + + $role = Role::firstOrCreate([ + 'name' => $teamName, + 'team_id' => $team->id, + 'guard_name' => 'web', + ]); + + $permissionNames = $this->permissionNames($config['abilities']); + + // Gabungkan, bukan ganti: menjamin matriks minimum selalu ada, tetapi + // tidak mencabut izin yang memang ditambahkan admin secara lokal. + $role->syncPermissions(array_values(array_unique(array_merge( + $role->permissions->pluck('name')->all(), + $permissionNames + )))); + } + + /** + * @param array> $abilities + * @return array + */ + private function permissionNames(array $abilities): array + { + $names = [Permission::findOrCreate(self::PARENT_PERMISSION, 'web')->name]; + + foreach ($abilities as $menu => $menuAbilities) { + foreach ($menuAbilities as $ability) { + $names[] = Permission::findOrCreate("dataspasial-{$menu}-{$ability}", 'web')->name; + } + } + + return array_values(array_unique($names)); + } + + /** + * Bangun struktur menu grup DataSpasial berisi hanya submenu yang diizinkan, + * mengikuti bentuk yang dibaca `App\Listeners\MenuListener`. + * + * Setiap item (grup & submenu) menyertakan flag ability + * `"{permission}-read|write|edit|delete"` dan `selected`, yaitu bentuk yang + * dipakai halaman Pengaturan › Grup (`resources/views/group/edit_js.blade.php` + * → `mergeByKey`) untuk mencentang hak akses, dan yang dibaca + * `App\Http\Controllers\Api\TeamController::collectPermissions()` saat + * grup disimpan. Tanpa flag ini kotak centang tidak muncul tercentang dan + * menyimpan grup dari UI akan menghapus izinnya. + * + * @param array $allowedMenus + * @param array> $abilities + * @return array> + */ + private function buildMenu(array $allowedMenus, array $abilities): array + { + $group = $this->dataspasialGroup(); + + if ($group === null) { + return []; + } + + $allowed = array_flip(array_map( + fn (string $menu) => 'dataspasial-'.$menu, + $allowedMenus + )); + + $submenus = []; + $groupAbilities = []; + + foreach ($group['submenu'] ?? [] as $item) { + $permission = $item['permission'] ?? ''; + + if (! isset($allowed[$permission])) { + continue; + } + + // permission submenu = "dataspasial-{slug}" → slug "skema", dst. + $slug = (string) preg_replace('/^dataspasial-/', '', $permission); + $itemAbilities = $abilities[$slug] ?? []; + + $submenus[] = $this->withAbilityFlags($item, $itemAbilities); + $groupAbilities = array_merge($groupAbilities, $itemAbilities); + } + + $groupItem = array_merge($group, ['submenu' => array_values($submenus)]); + + return [ + $this->withAbilityFlags($groupItem, array_values(array_unique($groupAbilities))), + ]; + } + + /** + * Tambahkan flag ability (`{permission}-{ability}`) dan `selected` ke item + * menu, sesuai bentuk yang diproduksi UI Pengaturan › Grup. + * + * @param array $item + * @param array $abilities + * @return array + */ + private function withAbilityFlags(array $item, array $abilities): array + { + $permission = (string) ($item['permission'] ?? ''); + + foreach (['read', 'write', 'edit', 'delete'] as $ability) { + $item[$permission.'-'.$ability] = in_array($ability, $abilities, true); + } + + $item['selected'] = true; + + return $item; + } + + /** + * Apakah menu team perlu diperbaiki? + * + * Dianggap perlu bila grup DataSpasial tidak ada, tidak punya submenu, atau + * item grup/submenu belum memuat flag ability (`{permission}-read`) yang + * dibutuhkan halaman Pengaturan › Grup. + */ + private function needsMenuRepair(mixed $menu): bool + { + foreach ((array) $menu as $group) { + if (($group['text'] ?? null) !== 'DataSpasial') { + continue; + } + + if (empty($group['submenu'])) { + return true; + } + + if (! array_key_exists(($group['permission'] ?? '').'-read', $group)) { + return true; + } + + foreach ($group['submenu'] as $item) { + if (! array_key_exists(($item['permission'] ?? '').'-read', $item)) { + return true; + } + } + + return false; + } + + return true; + } + + /** + * @return array|null + */ + private function dataspasialGroup(): ?array + { + foreach (Modul::Menu as $menu) { + if (($menu['text'] ?? null) === 'DataSpasial') { + return $menu; + } + } + + return null; + } +}; diff --git a/resources/views/components/dataspasial-status.blade.php b/resources/views/components/dataspasial-status.blade.php new file mode 100644 index 00000000..a44508be --- /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 00000000..754bdbeb --- /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 00000000..f5ea0e4f --- /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 00000000..5142d521 --- /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 00000000..6393913b --- /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 00000000..989627e8 --- /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 00000000..62ea21f6 --- /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 00000000..85f410d2 --- /dev/null +++ b/resources/views/dataspasial/unavailable.blade.php @@ -0,0 +1,20 @@ +{{-- + Pesan "data sementara tidak tersedia" (FR-014). + + WAJIB generik: tanpa detail teknis, nama endpoint, kode status HTTP, + atau jejak_exception. Detail tersebut hanya untuk log backend. +--}} + diff --git a/routes/web.php b/routes/web.php index a0adc458..67e3f142 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 00000000..8b041298 --- /dev/null +++ b/tests/Browser/SmokeDataSpasialMenuTest.php @@ -0,0 +1,94 @@ +user = SessionState::loginAdminUser(); +}); + +afterEach(function () { + SessionState::clear(); +}); + +/** + * @return array + */ +function dataspasialMenuLinks(): array +{ + return [ + 'Skema DataSpasial' => '/dataspasial/skema', + 'Peta Gabungan' => '/dataspasial/peta', + 'Dasbor DataSpasial' => '/dataspasial/dasbor', + 'Impor Data' => '/dataspasial/impor', + 'Monitoring' => '/dataspasial/monitoring', + ]; +} + +it('menampilkan grup menu DataSpasial beserta lima submenu', function () { + $page = SessionState::loginAndNavigate($this->user, '/dasbor') + ->assertPathIs('/dasbor'); + + $page->assertSee('DataSpasial'); + + // Submenu AdminLte dirender tersembunyi sampai grup diklik; karena itu + // keberadaan link diperiksa pada DOM, bukan visibilitasnya. + foreach (dataspasialMenuLinks() as $label => $path) { + $page->assertScript( + 'Array.from(document.querySelectorAll(".nav-sidebar a")) + .some(a => a.textContent.trim() === '.json_encode($label).' + && a.getAttribute("href").includes('.json_encode($path).'))', + true + ); + } + + ScreenshotHelper::saveIfEnabled($page, 'dataspasial-menu'); +}); + +it('membuka setiap halaman DataSpasial tanpa halaman error', function (string $path, string $title) { + $page = SessionState::loginAndNavigate($this->user, $path); + + $page->assertPathIs($path) + ->assertSee($title); + + ScreenshotHelper::saveIfEnabled($page, 'dataspasial'.str_replace('/', '-', $path)); +})->with([ + ['/dataspasial/skema', 'Skema DataSpasial'], + ['/dataspasial/peta', 'Peta Gabungan'], + ['/dataspasial/dasbor', 'Dasbor DataSpasial'], + ['/dataspasial/impor', 'Impor Data'], + ['/dataspasial/monitoring', 'Monitoring'], +]); + +it('menampilkan pesan data sementara tidak tersedia alih-alih halaman error', function () { + $page = SessionState::loginAndNavigate($this->user, '/dataspasial/peta'); + + // API tidak hidup pada lingkungan uji → harus jatuh ke kondisi unavailable. + $page->assertSee('Data sementara tidak tersedia') + ->assertDontSee('Whoops') + ->assertDontSee('Stack trace'); + + ScreenshotHelper::saveIfEnabled($page, 'dataspasial-unavailable'); +}); + +it('mempertahankan kerangka halaman dan navigasi saat data tidak tersedia', function () { + $page = SessionState::loginAndNavigate($this->user, '/dataspasial/monitoring'); + + // Layout admin tetap utuh: navigasi dan judul halaman masih terlihat. + $page->assertSee('Monitoring') + ->assertSee('DataSpasial') + ->assertSee('Peta Gabungan'); + + ScreenshotHelper::saveIfEnabled($page, 'dataspasial-unavailable-frame'); +}); diff --git a/tests/Feature/DataSpasial/DataSpasialApiServiceTest.php b/tests/Feature/DataSpasial/DataSpasialApiServiceTest.php new file mode 100644 index 00000000..7007a7ac --- /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 00000000..6793f3f2 --- /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 00000000..bfce425b --- /dev/null +++ b/tests/Feature/DataSpasial/DataSpasialResultTest.php @@ -0,0 +1,127 @@ + '1', 'type' => 'ds_sektor', 'attributes' => ['kode' => 'pendidikan']], + ['id' => '2', 'type' => 'ds_sektor', 'attributes' => ['kode' => 'kesehatan']], + ]; + $meta = ['pagination' => ['total' => 2, 'count' => 2, 'per_page' => 10, 'current_page' => 1, 'total_pages' => 1]]; + + $result = DataSpasialResult::data($items, $meta); + + expect($result->status)->toBeInstanceOf(DataSpasialStatus::class) + ->and($result->status->value)->toBe('data') + ->and($result->isData())->toBeTrue() + ->and($result->isEmpty())->toBeFalse() + ->and($result->isUnavailable())->toBeFalse() + ->and($result->reason)->toBeNull() + ->and($result->httpStatus)->toBe(200) + ->and($result->data)->toBe($items) + ->and($result->count())->toBe(2) + ->and($result->first())->toBe($items[0]) + ->and($result->pagination())->toBe($meta['pagination']); +}); + +it('menandai hasil kosong tanpa dianggap gagal', function () { + $meta = ['pagination' => ['total' => 0, 'count' => 0]]; + + $result = DataSpasialResult::empty($meta); + + expect($result->status)->toBeInstanceOf(DataSpasialStatus::class) + ->and($result->status->value)->toBe('empty') + ->and($result->isEmpty())->toBeTrue() + ->and($result->isData())->toBeFalse() + ->and($result->isUnavailable())->toBeFalse() + ->and($result->reason)->toBeNull() + ->and($result->data)->toBe([]) + ->and($result->count())->toBe(0) + ->and($result->first())->toBeNull() + ->and($result->pagination())->toBe($meta['pagination']); +}); + +it('menandai hasil tidak tersedia dengan penyebab', function () { + $result = DataSpasialResult::unavailable( + DataSpasialReason::fromValue(DataSpasialReason::ModuleNotInstalled), + 503, + 'Modul DataSpasial belum terpasang atau versinya tidak cocok' + ); + + expect($result->status)->toBeInstanceOf(DataSpasialStatus::class) + ->and($result->status->value)->toBe('unavailable') + ->and($result->isUnavailable())->toBeTrue() + ->and($result->isData())->toBeFalse() + ->and($result->isEmpty())->toBeFalse() + ->and($result->reason)->toBeInstanceOf(DataSpasialReason::class) + ->and($result->reason->value)->toBe('module_not_installed') + ->and($result->httpStatus)->toBe(503) + ->and($result->message)->toBe('Modul DataSpasial belum terpasang atau versinya tidak cocok') + ->and($result->data)->toBe([]) + ->and($result->count())->toBe(0) + ->and($result->first())->toBeNull() + ->and($result->pagination())->toBe([]); +}); + +it('menyediakan pesan generik untuk pengguna dan tidak membocorkan pesan teknis', function () { + $result = DataSpasialResult::unavailable( + DataSpasialReason::fromValue(DataSpasialReason::Timeout), + 0, + 'cURL error 28: Operation timed out after 10001 milliseconds' + ); + + expect($result->userMessage())->toBe('Data sementara tidak tersedia. Silakan coba lagi nanti.') + ->and($result->userMessage())->not->toContain('cURL error'); +}); + +it('memberikan pesan generik yang sama untuk semua penyebab', function () { + $messages = []; + + foreach (DataSpasialReason::getValues() as $reason) { + $messages[] = DataSpasialResult::unavailable(DataSpasialReason::fromValue($reason))->userMessage(); + } + + expect($messages)->toHaveCount(count(DataSpasialReason::getValues())) + ->and(array_unique($messages))->toHaveCount(1); +}); + +it('mengembalikan pagination kosong bila meta tidak tersedia', function () { + expect(DataSpasialResult::data([['id' => '1']])->pagination())->toBe([]) + ->and(DataSpasialResult::empty()->pagination())->toBe([]); +}); + +it('menolak data kosong pada status data', function () { + DataSpasialResult::data([]); +})->throws(InvalidArgumentException::class); + +it('menolak alasan pada status data dan kosong', function () { + expect(fn () => DataSpasialResult::data([['id' => '1']], [], DataSpasialReason::fromValue(DataSpasialReason::Timeout))) + ->toThrow(InvalidArgumentException::class) + ->and(fn () => DataSpasialResult::empty([], DataSpasialReason::fromValue(DataSpasialReason::Timeout))) + ->toThrow(InvalidArgumentException::class); +}); + +it('menolak hasil tidak tersedia tanpa alasan', function () { + $constructor = (new ReflectionClass(DataSpasialResult::class))->getConstructor(); + $constructor->setAccessible(true); + + expect(fn () => $constructor->invokeArgs( + (new ReflectionClass(DataSpasialResult::class))->newInstanceWithoutConstructor(), + [ + DataSpasialStatus::fromValue(DataSpasialStatus::Unavailable), + [], + [], + null, + 0, + null, + ] + ))->toThrow(InvalidArgumentException::class); +}); + +it('menyembunyikan constructor agar hanya dibangun lewat named constructor', function () { + expect((new ReflectionClass(DataSpasialResult::class))->getConstructor()->isPrivate())->toBeTrue(); +}); diff --git a/tests/Feature/DataSpasial/DataSpasialRoleMigrationTest.php b/tests/Feature/DataSpasial/DataSpasialRoleMigrationTest.php new file mode 100644 index 00000000..c7f913d6 --- /dev/null +++ b/tests/Feature/DataSpasial/DataSpasialRoleMigrationTest.php @@ -0,0 +1,340 @@ +up(); +} + +beforeEach(function () { + config([ + 'app.databaseGabunganUrl' => 'https://api.example.test', + 'dataspasial.cache_ttl' => 300, + ]); + + Cache::flush(); + + // Pastikan permission dasar hasil `admin:menu-update` sudah ada. + setPermissionsTeamId(Team::where('name', 'administrator')->first()->id); + Artisan::call('admin:menu-update'); + + runDataSpasialRolesMigration(); +}); + +function permissionsOf(string $teamName): array +{ + $team = Team::where('name', $teamName)->firstOrFail(); + setPermissionsTeamId($team->id); + + $role = Role::where('team_id', $team->id)->where('name', $teamName)->firstOrFail(); + + return $role->permissions->pluck('name')->sort()->values()->all(); +} + +/** + * @return array + */ +function teamMenu(string $teamName): array +{ + return Team::where('name', $teamName)->firstOrFail()->menu ?? []; +} + +/** + * @param array $abilities + * @return array + */ +function abilities(string $menu, array $abilities): array +{ + return array_map(fn (string $a) => "dataspasial-{$menu}-{$a}", $abilities); +} + +// --------------------------------------------------------------------------- +// T023 — Ketiga peran tersedia beserta izin +// --------------------------------------------------------------------------- + +it('menyediakan tiga peran DataSpasial', function (string $teamName) { + $team = Team::where('name', $teamName)->first(); + + expect($team)->not->toBeNull(); + + setPermissionsTeamId($team->id); + + $role = Role::where('team_id', $team->id)->where('name', $teamName)->first(); + + expect($role)->not->toBeNull() + ->and($role->guard_name)->toBe('web') + ->and($role->permissions->isNotEmpty())->toBeTrue(); +})->with(['admin skema', 'operator impor', 'analis opd']); + +it('memberikan permission menu parent pada setiap peran', function (string $teamName) { + expect(permissionsOf($teamName))->toContain('dataspasial'); +})->with(['admin skema', 'operator impor', 'analis opd']); + +it('memberikan akses penuh menu DataSpasial kepada role administrator', function () { + $team = Team::where('name', 'administrator')->firstOrFail(); + setPermissionsTeamId($team->id); + + $role = Role::where('team_id', $team->id)->where('name', 'administrator')->firstOrFail(); + $permissions = $role->permissions->pluck('name')->all(); + + // Permission diturunkan `admin:menu-update` dari App\Enums\Modul::Menu + // (grup DataSpasial + tiap submenu × ability). + foreach ([ + 'dataspasial-read', 'dataspasial-write', + 'dataspasial-skema-read', 'dataspasial-skema-write', 'dataspasial-skema-edit', 'dataspasial-skema-delete', + 'dataspasial-peta-read', + 'dataspasial-dasbor-read', + 'dataspasial-impor-read', 'dataspasial-impor-write', + 'dataspasial-monitoring-read', + ] as $permission) { + expect($permissions)->toContain($permission); + } + + $submenuTexts = []; + foreach (teamMenu('administrator') as $group) { + if (($group['text'] ?? null) === 'DataSpasial') { + $submenuTexts = array_column($group['submenu'] ?? [], 'text'); + } + } + + expect($submenuTexts)->toContain( + 'Skema DataSpasial', + 'Peta Gabungan', + 'Dasbor DataSpasial', + 'Impor Data', + 'Monitoring', + ); +}); + +// --------------------------------------------------------------------------- +// T036 — Matriks izin (FR-011) +// --------------------------------------------------------------------------- + +it('memberikan Admin Skema hak mengelola skema dan membaca halaman lain', function () { + $permissions = permissionsOf('admin skema'); + + foreach (abilities('skema', ['read', 'write', 'edit', 'delete']) as $permission) { + expect($permissions)->toContain($permission); + } + + foreach (abilities('peta', ['read']) + abilities('dasbor', ['read']) + abilities('monitoring', ['read']) as $permission) { + expect($permissions)->toContain($permission); + } + + expect($permissions)->not->toContain('dataspasial-impor-write') + ->and($permissions)->not->toContain('dataspasial-impor-read'); +}); + +it('memberikan Operator Impor hak impor tetapi bukan mengelola skema', function () { + $permissions = permissionsOf('operator impor'); + + expect($permissions)->toContain('dataspasial-impor-read') + ->and($permissions)->toContain('dataspasial-impor-write') + ->and($permissions)->not->toContain('dataspasial-skema-write') + ->and($permissions)->not->toContain('dataspasial-skema-edit') + ->and($permissions)->not->toContain('dataspasial-skema-delete'); +}); + +it('memberikan Analis OPD hanya hak baca tanpa kemampuan tulis', function () { + $permissions = permissionsOf('analis opd'); + + foreach (abilities('peta', ['read']) + abilities('dasbor', ['read']) + abilities('monitoring', ['read']) as $permission) { + expect($permissions)->toContain($permission); + } + + foreach ($permissions as $permission) { + expect($permission)->not->toEndWith('-write') + ->and($permission)->not->toEndWith('-edit') + ->and($permission)->not->toEndWith('-delete'); + } +}); + +it('menyimpan menu grup pada team sesuai matriks izin', function () { + $adminSkemaTexts = array_column(teamMenu('admin skema')[0]['submenu'] ?? [], 'text'); + $operatorImporTexts = array_column(teamMenu('operator impor')[0]['submenu'] ?? [], 'text'); + $analisTexts = array_column(teamMenu('analis opd')[0]['submenu'] ?? [], 'text'); + + expect($adminSkemaTexts)->toContain('Skema DataSpasial') + ->and($adminSkemaTexts)->not->toContain('Impor Data') + ->and($operatorImporTexts)->toContain('Impor Data') + ->and($operatorImporTexts)->not->toContain('Skema DataSpasial') + ->and($analisTexts)->not->toContain('Skema DataSpasial') + ->and($analisTexts)->not->toContain('Impor Data'); +}); + +it('menyimpan flag hak akses pada menu team agar tercentang di Pengaturan Grup', function () { + // Halaman Pengaturan › Grup membaca flag "{permission}-read|write|edit|delete" + // dari team.menu (lihat resources/views/group/edit_js.blade.php). Tanpa flag + // ini kotak centang tidak tercentang. + $adminGroup = null; + foreach (teamMenu('admin skema') as $group) { + if (($group['text'] ?? null) === 'DataSpasial') { + $adminGroup = $group; + } + } + + expect($adminGroup)->not->toBeNull() + ->and($adminGroup['dataspasial-read'])->toBeTrue() + ->and($adminGroup['selected'])->toBeTrue(); + + $skema = collect($adminGroup['submenu'])->firstWhere('permission', 'dataspasial-skema'); + + expect($skema)->not->toBeNull() + ->and($skema['dataspasial-skema-read'])->toBeTrue() + ->and($skema['dataspasial-skema-write'])->toBeTrue() + ->and($skema['dataspasial-skema-edit'])->toBeTrue() + ->and($skema['dataspasial-skema-delete'])->toBeTrue() + ->and($skema['selected'])->toBeTrue(); + + // Analis OPD: read saja pada Peta Gabungan. + $analisGroup = null; + foreach (teamMenu('analis opd') as $group) { + if (($group['text'] ?? null) === 'DataSpasial') { + $analisGroup = $group; + } + } + + $peta = collect($analisGroup['submenu'])->firstWhere('permission', 'dataspasial-peta'); + + expect($peta['dataspasial-peta-read'])->toBeTrue() + ->and($peta['dataspasial-peta-write'])->toBeFalse() + ->and($peta['dataspasial-peta-edit'])->toBeFalse() + ->and($peta['dataspasial-peta-delete'])->toBeFalse(); +}); + +it('memperbaiki menu team lama yang belum memuat flag hak akses', function () { + $team = Team::where('name', 'admin skema')->firstOrFail(); + + // Simulasikan menu versi lama: grup & submenu tanpa flag ability. + $legacy = [[ + 'text' => 'DataSpasial', + 'icon' => 'fas fa-map', + 'permission' => 'dataspasial', + 'submenu' => [ + ['text' => 'Skema DataSpasial', 'url' => 'dataspasial/skema', 'permission' => 'dataspasial-skema'], + ], + ]]; + $team->update(['menu' => $legacy]); + + runDataSpasialRolesMigration(); + + $group = null; + foreach (teamMenu('admin skema') as $item) { + if (($item['text'] ?? null) === 'DataSpasial') { + $group = $item; + } + } + + expect($group['dataspasial-read'])->toBeTrue() + ->and(collect($group['submenu'])->firstWhere('permission', 'dataspasial-skema')['dataspasial-skema-read'])->toBeTrue(); +}); + +// --------------------------------------------------------------------------- +// T037 — Idempotensi +// --------------------------------------------------------------------------- + +it('aman dijalankan berulang tanpa menggandakan data', function () { + $teamsBefore = Team::whereIn('name', ['admin skema', 'operator impor', 'analis opd'])->count(); + + $rolesBefore = 0; + foreach (['admin skema', 'operator impor', 'analis opd'] as $name) { + $team = Team::where('name', $name)->first(); + setPermissionsTeamId($team->id); + $rolesBefore += Role::where('team_id', $team->id)->count(); + } + + runDataSpasialRolesMigration(); + runDataSpasialRolesMigration(); + + expect(Team::whereIn('name', ['admin skema', 'operator impor', 'analis opd'])->count())->toBe($teamsBefore); + + $rolesAfter = 0; + foreach (['admin skema', 'operator impor', 'analis opd'] as $name) { + $team = Team::where('name', $name)->first(); + setPermissionsTeamId($team->id); + $rolesAfter += Role::where('team_id', $team->id)->count(); + } + + expect($rolesAfter)->toBe($rolesBefore); +}); + +it('menjaga izin yang sudah ada pada team ketika dijalankan ulang', function () { + $team = Team::where('name', 'analis opd')->firstOrFail(); + setPermissionsTeamId($team->id); + + $role = Role::where('team_id', $team->id)->where('name', 'analis opd')->firstOrFail(); + $role->givePermissionTo(Permission::findOrCreate('dataspasial-peta-write', 'web')); + + runDataSpasialRolesMigration(); + + expect(permissionsOf('analis opd'))->toContain('dataspasial-peta-write'); +}); + +it('membuat ulang permission DataSpasial bila hilang', function () { + Permission::where('name', 'like', 'dataspasial%')->delete(); + + runDataSpasialRolesMigration(); + + expect(permissionsOf('admin skema'))->toContain('dataspasial-skema-read'); +}); + +// --------------------------------------------------------------------------- +// T038 — Penugasan pengguna +// --------------------------------------------------------------------------- + +it('menegakkan kontrol akses setelah pengguna ditugaskan ke peran', function () { + Http::fake([ + '*/api/v1/config/*' => Http::response(['data' => []], 200), + '*' => Http::response([ + 'data' => [['type' => 'ds_sektor', 'id' => '1', 'attributes' => []]], + 'meta' => ['pagination' => ['total' => 1, 'count' => 1]], + 'links' => [], + ], 200), + ]); + + $team = Team::where('name', 'analis opd')->firstOrFail(); + setPermissionsTeamId($team->id); + + $user = User::factory()->create(); + $user->teams()->attach($team->id, ['id_user' => $user->id, 'id_team' => $team->id]); + $user->unsetRelation('team')->load('team'); + $user->assignRole(Role::where('team_id', $team->id)->where('name', 'analis opd')->firstOrFail()); + + $this->actingAs($user, 'web'); + + $this->get(route('dataspasial.peta'))->assertOk(); + $this->get(route('dataspasial.dasbor'))->assertOk(); + $this->get(route('dataspasial.monitoring'))->assertOk(); + $this->get(route('dataspasial.skema'))->assertForbidden(); + $this->get(route('dataspasial.impor'))->assertForbidden(); +}); + +it('memastikan grup DataSpasial ada di App\Enums\Modul::Menu sebagai sumber kebenaran', function () { + $texts = array_column(Modul::Menu, 'text'); + + expect($texts)->toContain('DataSpasial'); +}); diff --git a/tests/Feature/DataSpasial/DataSpasialUnavailableTest.php b/tests/Feature/DataSpasial/DataSpasialUnavailableTest.php new file mode 100644 index 00000000..ff8e6c25 --- /dev/null +++ b/tests/Feature/DataSpasial/DataSpasialUnavailableTest.php @@ -0,0 +1,211 @@ + 'https://api.example.test', + 'dataspasial.cache_ttl' => 300, + ]); + + Setting::updateOrCreate( + ['key' => 'database_gabungan_api_key'], + ['name' => 'API Key', 'value' => 'token-uji'] + ); + + Cache::flush(); + + Http::fake([ + '*/api/v1/config/*' => Http::response(['data' => []], 200), + ]); + + $team = Team::firstOrCreate(['name' => 'analis opd 503'], ['menu' => []]); + setPermissionsTeamId($team->id); + + $role = Role::firstOrCreate(['name' => 'analis opd 503', 'team_id' => $team->id, 'guard_name' => 'web']); + $role->syncPermissions([ + Permission::findOrCreate('dataspasial-peta-read', 'web'), + Permission::findOrCreate('dataspasial-dasbor-read', 'web'), + Permission::findOrCreate('dataspasial-monitoring-read', 'web'), + ]); + + $user = User::factory()->create(); + $user->teams()->attach($team->id, ['id_user' => $user->id, 'id_team' => $team->id]); + $user->unsetRelation('team')->load('team'); + $user->assignRole($role); + + $this->actingAs($user, 'web'); +}); + +/** + * @return array + */ +function dataspasialRoutes(): array +{ + return ['dataspasial.peta', 'dataspasial.dasbor', 'dataspasial.monitoring']; +} + +// --------------------------------------------------------------------------- +// T043 — 503 modul belum terpasang +// --------------------------------------------------------------------------- + +it('menampilkan pesan data sementara tidak tersedia saat modul belum terpasang', function (string $route) { + Http::fake([ + '*/api/v1/config/*' => Http::response(['data' => []], 200), + '*' => Http::response([ + 'success' => false, + 'message' => 'Modul DataSpasial belum terpasang atau versinya tidak cocok', + ], 503), + ]); + + $response = $this->get(route($route)); + + $response->assertOk() + ->assertSee('Data sementara tidak tersedia') + ->assertSee('dataspasial-unavailable', false); +})->with(dataspasialRoutes()); + +// --------------------------------------------------------------------------- +// T044 — Timeout +// --------------------------------------------------------------------------- + +it('menampilkan pesan yang sama saat API melewati batas waktu', function (string $route) { + Http::fake([ + '*/api/v1/config/*' => Http::response(['data' => []], 200), + '*' => fn () => throw new ConnectionException('cURL error 28: Operation timed out'), + ]); + + $this->get(route($route)) + ->assertOk() + ->assertSee('Data sementara tidak tersedia'); +})->with(dataspasialRoutes()); + +// --------------------------------------------------------------------------- +// T045 — Konfigurasi tidak lengkap +// --------------------------------------------------------------------------- + +it('menampilkan pesan yang sama saat konfigurasi database gabungan tidak lengkap', function (string $route) { + Setting::where('key', 'database_gabungan_api_key')->delete(); + + $this->get(route($route)) + ->assertOk() + ->assertSee('Data sementara tidak tersedia'); +})->with(dataspasialRoutes()); + +// --------------------------------------------------------------------------- +// T046 — Empty state (FR-015) +// --------------------------------------------------------------------------- + +it('menampilkan keadaan kosong dan bukan pesan gagal saat API sukses tanpa data', function (string $route) { + Http::fake([ + '*/api/v1/config/*' => Http::response(['data' => []], 200), + '*' => Http::response([ + 'data' => [], + 'meta' => ['pagination' => ['total' => 0, 'count' => 0, 'per_page' => 10, 'current_page' => 1, 'total_pages' => 1]], + 'links' => [], + ], 200), + ]); + + $response = $this->get(route($route)); + + $response->assertOk() + ->assertSee('Belum ada data') + ->assertSee('dataspasial-empty', false) + ->assertDontSee('Data sementara tidak tersedia'); +})->with(dataspasialRoutes()); + +it('menampilkan isi halaman saat API sukses berisi data', function () { + Http::fake([ + '*/api/v1/config/*' => Http::response(['data' => []], 200), + '*' => Http::response([ + 'data' => [['type' => 'ds_adopsi', 'id' => '1', 'attributes' => ['status_adopsi' => 'terbaru', 'perkuliahan' => '2025-06']]], + 'meta' => ['pagination' => ['total' => 1, 'count' => 1]], + 'links' => [], + ], 200), + ]); + + $this->get(route('dataspasial.monitoring')) + ->assertOk() + ->assertSee('Status Adopsi Skema per Desa') + ->assertSee('terbaru') + ->assertDontSee('Data sementara tidak tersedia') + ->assertDontSee('Belum ada data'); +}); + +// --------------------------------------------------------------------------- +// T047 — Kerangka halaman tetap utuh +// --------------------------------------------------------------------------- + +it('tetap menampilkan navigasi dan kerangka halaman saat API bermasalah', function () { + Http::fake([ + '*/api/v1/config/*' => Http::response(['data' => []], 200), + '*' => Http::response(['success' => false, 'message' => 'Modul DataSpasial belum terpasang'], 503), + ]); + + $content = $this->get(route('dataspasial.peta'))->assertOk()->getContent(); + + // Layout adminlte tetap dirender utuh: judul halaman, navigasi sidebar, + // dan elemen kerangka halaman tetap ada meski data tidak bisa diperoleh. + expect($content)->toContain('Peta Gabungan') + ->and($content)->toContain('logout') + ->and($content)->toContain('content-header') + ->and($content)->toContain('and($content)->toContain('Data sementara tidak tersedia'); +}); + +it('tidak pernah menampilkan stack trace atau jejak exception', function (string $route) { + Http::fake([ + '*/api/v1/config/*' => Http::response(['data' => []], 200), + '*' => fn () => throw new ConnectionException('cURL error 28: Operation timed out after 10001 ms with /var/www/secret/path.php'), + ]); + + $content = $this->get(route($route))->assertOk()->getContent(); + + expect($content) + ->not->toContain('cURL error') + ->not->toContain('/var/www/secret') + ->not->toContain('Stack trace') + ->not->toContain('#0 /'); +})->with(dataspasialRoutes()); + +// --------------------------------------------------------------------------- +// T048 — Log tanpa membocorkan detail ke pengguna +// --------------------------------------------------------------------------- + +it('tidak menampilkan pesan teknis API kepada pengguna tetapi mencatatnya di log', function () { + Log::spy(); + + Http::fake([ + '*/api/v1/config/*' => Http::response(['data' => []], 200), + '*' => Http::response([ + 'success' => false, + 'message' => 'Modul DataSpasial belum terpasang atau versinya tidak cocok', + ], 503), + ]); + + $content = $this->get(route('dataspasial.peta'))->assertOk()->getContent(); + + expect($content)->not->toContain('Modul DataSpasial belum terpasang'); + + Log::shouldHaveReceived('warning') + ->withArgs(fn (string $message, array $context = []) => str_contains($message, 'DataSpasial') + && ($context['reason'] ?? null) === 'module_not_installed'); +});