null, 'master_kabupaten_kota' => 'provinsi_id', 'master_kecamatan' => 'kabupaten_kota_id', 'master_desa_kelurahan' => 'kecamatan_id', ] as $name => $parent) { Schema::create($name, function (Blueprint $table) use ($parent) { $table->id(); $table->string('nama'); $table->string('kode')->nullable(); if ($parent) { $table->integer($parent); } $table->softDeletes(); }); } Schema::create('users', function (Blueprint $table) { $table->id(); $table->string('name'); $table->softDeletes(); }); Schema::create('activity_log', function (Blueprint $table) { $table->id(); $table->string('log_name')->nullable(); $table->text('description'); $table->nullableMorphs('subject'); $table->string('event')->nullable(); $table->nullableMorphs('causer'); $table->json('properties')->nullable(); $table->uuid('batch_uuid')->nullable(); $table->timestamps(); }); Schema::create('bumdes_pengurus', function (Blueprint $table) { $table->id(); foreach (['uid', 'tempat_lahir', 'pendidikan_terakhir', 'alamat', 'nomor_telepon'] as $field) { $table->string($field)->nullable(); } $table->date('tanggal_lahir')->nullable(); $table->date('tanggal_mulai_menjabat')->nullable(); $table->integer('lama_menjabat_tahun')->nullable(); $table->integer('updated_by')->nullable(); $table->integer('deleted_by')->nullable(); $table->string('nama'); $table->boolean('jenis_kelamin'); $table->string('jabatan'); $table->string('jabatan_lainnya')->nullable(); $table->integer('desa_kelurahan_id'); $table->integer('created_by'); $table->timestamps(); $table->softDeletes(); }); (require database_path('migrations/2026_08_20_000001_create_bumdes_kelembagaan_aktor_table.php'))->up(); (require database_path('migrations/2026_11_04_000001_add_dukungan_ekosistem_to_bumdes_kelembagaan_aktor_table.php'))->up(); (require database_path('migrations/2026_11_04_000002_add_pengelolaan_layanan_to_bumdes_kelembagaan_aktor_table.php'))->up(); (require database_path('migrations/2026_11_04_000003_add_rantai_nilai_to_bumdes_kelembagaan_aktor_table.php'))->up(); (require database_path('migrations/2026_11_05_000001_add_peluang_nilai_tambah_to_bumdes_kelembagaan_aktor_table.php'))->up(); DB::table('users')->insert([ ['id' => 7, 'name' => 'Admin Test'], ['id' => 8, 'name' => 'Enumerator'], ]); DB::table('master_provinsi')->insert(['id' => 1, 'nama' => 'NTT']); DB::table('master_kabupaten_kota')->insert(['id' => 1, 'nama' => 'Kabupaten Ngada', 'provinsi_id' => 1]); DB::table('master_kecamatan')->insert([ ['id' => 1, 'nama' => 'Kecamatan A', 'kabupaten_kota_id' => 1], ['id' => 2, 'nama' => 'Kecamatan B', 'kabupaten_kota_id' => 1], ]); DB::table('master_desa_kelurahan')->insert([ ['id' => 1, 'nama' => 'Desa A', 'kecamatan_id' => 1], ['id' => 2, 'nama' => 'Desa B', 'kecamatan_id' => 2], ]); foreach ([ [1, 'Alpha', 1, 'direktur', 1, 7, null], [2, 'Beta', 0, 'direktur_ketua', 1, 8, null], [3, 'Citra', 0, 'sekretaris', 2, 7, null], [4, 'Dihapus', 0, 'bendahara', 1, 7, '2026-01-01'], ] as [$id, $nama, $gender, $jabatan, $desa, $creator, $deleted]) { DB::table('bumdes_pengurus')->insert([ 'tempat_lahir' => 'Ngada', 'tanggal_lahir' => '1990-01-01', 'pendidikan_terakhir' => '04_sma', 'alamat' => 'Desa A', 'lama_menjabat_tahun' => 2, 'id' => $id, 'nama' => $nama, 'jenis_kelamin' => $gender, 'jabatan' => $jabatan, 'desa_kelurahan_id' => $desa, 'created_by' => $creator, 'created_at' => "2026-01-0{$id}", 'deleted_at' => $deleted, ]); } $user = new User(['name' => 'Admin Test', 'role' => 'admin']); $user->id = 7; $this->be($user); Event::forget('composing: *'); view()->share('errors', new ViewErrorBag); $this->bumdesAction = function (string $action, ?Request $base = null, ?string $id = null) { [$controllerClass, $method, $routeName] = match ($action) { 'kelembagaan.index' => [KelembagaanAktorBumdesController::class, 'index', 'bumdes.kelembagaan-aktor.index'], 'kelembagaan.store' => [KelembagaanAktorBumdesController::class, 'store', 'bumdes.kelembagaan-aktor.store'], 'rantai-nilai.index' => [RantaiNilaiBumdesController::class, 'index', 'bumdes.rantai-nilai.index'], 'rantai-nilai.store' => [RantaiNilaiBumdesController::class, 'store', 'bumdes.rantai-nilai.store'], 'operasional.index' => [OperasionalBumdesController::class, 'index', 'bumdes.operasional.index'], 'operasional.store' => [OperasionalBumdesController::class, 'store', 'bumdes.operasional.store'], default => [PengurusBumdesController::class, $action, 'bumdes.'.$action], }; $base ??= Request::create('/bumdes'.($id ? '/'.$id : '')); $base->setLaravelSession(app('session')->driver()); $model = $id ? PengurusBumdes::findOrFail($id) : null; $route = new Route(['GET', 'POST', 'PUT', 'DELETE'], 'bumdes/{pengurusBumdes?}', fn () => null); $route->name($routeName)->bind(Request::create('/bumdes'.($id ? '/'.$id : ''))); if ($model) { $route->setParameter('pengurusBumdes', $model); } $requestClass = (new ReflectionMethod($controllerClass, $method))->getParameters()[0]->getType()->getName(); $request = $requestClass::createFrom($base); $request->setContainer($this->app)->setRedirector(app('redirect')); $request->setUserResolver(fn () => auth()->user())->setRouteResolver(fn () => $route); $this->app->instance('request', $request); $request->validateResolved(); $arguments = $model ? [$request, $model] : [$request]; return app($controllerClass)->$method(...$arguments); }; $this->bumdesList = function (array $filters = []) { $request = Request::create('/bumdes', 'GET', $filters); $request->setLaravelSession(app('session')->driver()); $this->app->instance('request', $request); return ($this->bumdesAction)('index', $request); }; $this->profileInput = [ 'nama' => 'Responden', 'jenis_kelamin' => '1', 'tempat_lahir' => 'Ngada', 'tanggal_lahir' => '01-01-1990', 'pendidikan_terakhir' => '04_sma', 'alamat' => 'Desa A', 'provinsi_id' => 1, 'kabupaten_id' => 1, 'kecamatan_id' => 1, 'desa_kelurahan_id' => 1, 'jabatan' => 'sekretaris', 'lama_menjabat_tahun' => 2, ]; }); test('bumdes activity and location filters update rows and summaries', function () { $data = ($this->bumdesList)()->getData(); expect($data['totalBumdes'])->toBe(3)->and($data['totalBumdesAktif'])->toBe(0)->and($data['totalBerbadanHukum'])->toBe(0); foreach ([ [1, 'aktif_penuh', 'sudah_berbadan_hukum'], [2, 'tidak_aktif_vakum', 'belum_berbadan_hukum'], [3, 'aktif_terbatas', 'proses_pendaftaran'], [4, 'aktif_penuh', 'sudah_berbadan_hukum'], ] as [$id, $status, $hukum]) { KelembagaanAktorBumdes::create([ 'pengurus_bumdes_id' => $id, 'status_keaktifan' => $status, 'status_badan_hukum' => $hukum, ]); } $data = ($this->bumdesList)()->getData(); expect($data['totalBumdes'])->toBe(3)->and($data['totalBumdesAktif'])->toBe(2) ->and($data['totalBerbadanHukum'])->toBe(1)->and($data['totalDesaKelurahan'])->toBe(2); $data = ($this->bumdesList)(['status_keaktifan' => 'aktif_penuh'])->getData(); expect($data['pengurus']->pluck('id')->all())->toBe([1]); expect($data['totalBumdes'])->toBe(1)->and($data['totalDesaKelurahan'])->toBe(1) ->and($data['totalBumdesAktif'])->toBe(1)->and($data['totalBerbadanHukum'])->toBe(1); $data = ($this->bumdesList)(['kecamatan_id' => 1, 'desa_kelurahan_id' => 1])->getData(); expect($data['pengurus']->pluck('id')->all())->toBe([2, 1]); expect($data['totalBumdes'])->toBe(2); expect($data['desaOptions']->pluck('id')->all())->toBe([1]); expect(($this->bumdesList)(['search' => 'aLpHa'])->getData()['pengurus']->pluck('id')->all())->toBe([1]); expect(($this->bumdesList)(['search' => 'dEsA a'])->getData()['pengurus']->pluck('id')->all())->toBe([2, 1]); KelembagaanAktorBumdes::where('pengurus_bumdes_id', 1)->firstOrFail()->delete(); expect(($this->bumdesList)(['status_keaktifan' => 'aktif_penuh'])->getData()['pengurus']->count())->toBe(0); $data = ($this->bumdesList)()->getData(); expect($data['totalBumdesAktif'])->toBe(1)->and($data['totalBerbadanHukum'])->toBe(0); $data = ($this->bumdesList)([ 'jenis_kelamin' => '1', 'jabatan' => 'sekretaris', 'status_badan_hukum' => 'invalid', 'unit_usaha' => 'invalid', ])->getData(); expect($data['pengurus']->pluck('id')->all())->toBe([3, 2, 1]); expect($data['filters'])->not->toHaveKeys(['jenis_kelamin', 'jabatan', 'status_badan_hukum', 'unit_usaha']); }); test('bumdes search and summaries respect enumerator ownership', function () { auth()->user()->role = 'enumerator'; foreach ([1, 2] as $id) { KelembagaanAktorBumdes::create([ 'pengurus_bumdes_id' => $id, 'status_keaktifan' => 'aktif_penuh', 'status_badan_hukum' => 'sudah_berbadan_hukum', ]); } $data = ($this->bumdesList)(['search' => 'Desa A', 'status_keaktifan' => 'aktif_penuh'])->getData(); expect($data['pengurus']->pluck('id')->all())->toBe([1]); expect($data['totalBumdes'])->toBe(1)->and($data['showEnumerator'])->toBeFalse() ->and($data['totalBumdesAktif'])->toBe(1)->and($data['totalBerbadanHukum'])->toBe(1); }); test('bumdes sorting and pagination preserve filters across all results', function () { foreach (['nama_asc' => [2, 1, 3], 'nama_desc' => [3, 2, 1], 'unknown' => [3, 2, 1]] as $sort => $ids) { expect(($this->bumdesList)(['sort_by' => $sort])->getData()['pengurus']->pluck('id')->all())->toBe($ids); } KelembagaanAktorBumdes::create(['pengurus_bumdes_id' => 2, 'status_keaktifan' => 'aktif_penuh']); for ($i = 0; $i < 12; $i++) { $id = DB::table('bumdes_pengurus')->insertGetId([ 'nama' => "Tambahan {$i}", 'jenis_kelamin' => 0, 'jabatan' => 'sekretaris', 'desa_kelurahan_id' => 1, 'created_by' => 7, ]); KelembagaanAktorBumdes::create(['pengurus_bumdes_id' => $id, 'status_keaktifan' => 'aktif_penuh']); } $data = ($this->bumdesList)(['status_keaktifan' => 'aktif_penuh', 'sort_by' => 'nama_asc', 'per_page' => 10, 'page' => 2])->getData(); expect($data['totalBumdes'])->toBe(13)->and($data['pengurus']->count())->toBe(3); parse_str(parse_url($data['pengurus']->url(1), PHP_URL_QUERY), $params); expect($params)->toMatchArray(['status_keaktifan' => 'aktif_penuh', 'sort_by' => 'nama_asc', 'per_page' => '10']); }); test('bumdes filters reject malformed input', function (array $filters) { expect(fn () => ($this->bumdesList)($filters))->toThrow(ValidationException::class); })->with([ [['status_keaktifan' => 'invalid']], [['kecamatan_id' => 999]], [['per_page' => 0]], [['search' => ['invalid']]], ]); test('bumdes list and respondent forms render successfully', function () { KelembagaanAktorBumdes::create([ 'pengurus_bumdes_id' => 1, 'status_keaktifan' => 'aktif_penuh', 'status_badan_hukum' => 'proses_pendaftaran', 'unit_usaha' => ['simpan_pinjam', 'jasa_lainnya'], 'unit_usaha_jasa_lainnya' => 'Pengiriman hasil panen', ]); $view = ($this->bumdesList)(); expect($view->getData()['pengurus']->every(fn ($item) => $item->relationLoaded('kelembagaanAktor')))->toBeTrue(); $html = $view->render(); expect($html)->toContain('bumdes-row', 'bumdes-filter-form', 'Semua Keaktifan', 'Data BUMDes', 'BUMDes ยท Desa A', 'Aktif beroperasi penuh', 'Proses pendaftaran', 'Simpan pinjam', 'Pengiriman hasil panen', 'Belum terisi'); $dom = new DOMDocument; $previous = libxml_use_internal_errors(true); $dom->loadHTML($html); libxml_clear_errors(); libxml_use_internal_errors($previous); $xpath = new DOMXPath($dom); $overview = $xpath->query('//*[@id="bumdes-overview"]')->item(0); expect($overview->textContent)->toContain('Total BUMDes', 'BUMDes Aktif', 'Berbadan Hukum', 'desa/kelurahan terdata') ->not->toContain('Pengurus BUMDes', 'Jabatan/Peran', 'Laki-laki', 'Perempuan'); expect($xpath->query('//form[@id="bumdes-filter-form"]//select[@name="status_badan_hukum" or @name="unit_usaha" or @name="jenis_kelamin" or @name="jabatan"]')->length)->toBe(0); $header = $xpath->query('//table[contains(@class, "bumdes-list-table")]/thead')->item(0); expect($header->textContent)->toContain('BUMDes', 'Keaktifan', 'Badan Hukum', 'Unit Usaha', 'Nama Responden') ->not->toContain('Jenis Kelamin', 'Usia', 'Jabatan/Peran', 'Lama Menjabat'); expect($xpath->query('//form[contains(@class, "bumdes-delete-form") and contains(@data-nama, "BUMDes")]')->length)->toBe(3); $controller = $this->bumdesAction; foreach ([$controller('create'), $controller('edit', null, '1')] as $view) { $html = $view->render(); expect($html)->toContain('Data Responden', 'tabler-check me-2', '> Simpan') ->not->toContain('Data Pribadi', 'Peran Kepengurusan BUMDes', 'Domisili dan Kontak', 'Edit Profil Pengurus BUMDes', 'Tambah Profil Pengurus BUMDes'); } }); test('bumdes institutional and operational forms display the correct fields and saved dropdown selections', function () { KelembagaanAktorBumdes::create([ 'pengurus_bumdes_id' => 1, 'status_badan_hukum' => 'proses_pendaftaran', 'unit_usaha' => ['simpan_pinjam', 'jasa_lainnya'], 'unit_usaha_jasa_lainnya' => 'Pengiriman', 'status_ad_art' => 'ada_lengkap', 'tahun_ad_art' => 2025, 'status_keaktifan' => 'tidak_aktif_vakum', 'alasan_tidak_aktif' => 'Kekurangan modal', 'pengurus_inti' => [['jabatan' => 'sekretaris', 'nama' => 'Sekretaris Lama', 'aktif' => true]], ]); ($this->bumdesList)(); $controller = $this->bumdesAction; $html = $controller('kelembagaan.index', null, '1')->render(); $dom = new DOMDocument; $previous = libxml_use_internal_errors(true); $dom->loadHTML($html); libxml_clear_errors(); libxml_use_internal_errors($previous); $xpath = new DOMXPath($dom); expect($xpath->query('//select[@name="status_badan_hukum"]/option[@selected]')->item(0)->getAttribute('value'))->toBe('proses_pendaftaran'); expect($xpath->query('//input[@name="unit_usaha[]" and @type="checkbox" and @checked]')->length)->toBe(2); expect($xpath->query('//select[@name="unit_usaha[]"]')->length)->toBe(0); expect($xpath->query('//select[@name="status_unit_komoditas" or @name="status_ad_art"]')->length)->toBe(2); expect($html)->toContain('Status Legalitas BUMDes', route('bumdes.operasional.index', 1)) ->not->toContain('name="status_keaktifan"', 'Kepengurusan dan Staf', 'Modal dan Aset'); $html = $controller('operasional.index', null, '1')->render(); expect($html)->toContain('Modal dan Aset', route('bumdes.operasional.store', 1)) ->not->toContain('name="status_badan_hukum"', 'name="unit_usaha[]"', 'name="status_keaktifan"', 'Kepengurusan dan Staf'); foreach ([$controller('edit', null, '1'), $controller('create')] as $view) { $html = $view->render(); expect($html)->toContain('Status Keaktifan BUMDes', 'Kepengurusan dan Staf', 'Daftar Pengurus', 'switch switch-lg', 'role="switch"') ->not->toContain('Catat pengurus inti yang aktif menjabat saat ini', 'Modal dan Aset'); } expect($controller('edit', null, '1')->render())->toContain('Sekretaris Lama', 'Kekurangan modal'); }); test('institutional conditional fields are required on the server', function (array $input, array $expectedErrors) { $request = Request::create('/bumdes/1/kelembagaan-aktor', 'POST', $input); $this->app->instance('request', $request); try { ($this->bumdesAction)('kelembagaan.store', $request, '1'); $this->fail('Expected conditional validation errors.'); } catch (ValidationException $exception) { expect(array_keys($exception->errors()))->toBe($expectedErrors); foreach ($exception->errors() as $messages) { expect($messages[0])->toContain('wajib diisi.'); } } expect(KelembagaanAktorBumdes::count())->toBe(0); })->with([ [['status_badan_hukum' => 'sudah_berbadan_hukum'], ['nomor_dasar_hukum', 'tahun_dasar_hukum']], [['status_unit_komoditas' => 'ya'], ['nama_unit_komoditas']], [['status_ad_art' => 'ada_lengkap'], ['tahun_ad_art']], [[ 'status_badan_hukum' => 'sudah_berbadan_hukum', 'status_unit_komoditas' => 'ya', 'status_ad_art' => 'ada_lengkap', ], ['nomor_dasar_hukum', 'tahun_dasar_hukum', 'nama_unit_komoditas', 'tahun_ad_art']], ]); test('disabled institutional fields ignore submitted values and clear stale data', function () { KelembagaanAktorBumdes::create([ 'pengurus_bumdes_id' => 1, 'status_badan_hukum' => 'sudah_berbadan_hukum', 'nomor_dasar_hukum' => 'SK-01', 'tahun_dasar_hukum' => 2025, 'status_unit_komoditas' => 'ya', 'nama_unit_komoditas' => 'Kopi', 'status_ad_art' => 'ada_lengkap', 'tahun_ad_art' => 2025, ]); $request = Request::create('/bumdes/1/kelembagaan-aktor', 'POST', [ 'status_badan_hukum' => 'proses_pendaftaran', 'nomor_dasar_hukum' => ['invalid'], 'tahun_dasar_hukum' => 'invalid', 'status_unit_komoditas' => 'direncanakan', 'nama_unit_komoditas' => ['invalid'], 'status_ad_art' => 'tidak_ada', 'tahun_ad_art' => 'invalid', ]); $this->app->instance('request', $request); ($this->bumdesAction)('kelembagaan.store', $request, '1'); $data = KelembagaanAktorBumdes::firstOrFail(); expect($data->nomor_dasar_hukum)->toBeNull()->and($data->tahun_dasar_hukum)->toBeNull() ->and($data->nama_unit_komoditas)->toBeNull()->and($data->tahun_ad_art)->toBeNull(); }); test('institutional dependent inputs stay visible and have matching disabled required and asterisk states', function (bool $active) { KelembagaanAktorBumdes::create([ 'pengurus_bumdes_id' => 1, 'status_badan_hukum' => $active ? 'sudah_berbadan_hukum' : 'proses_pendaftaran', 'status_unit_komoditas' => $active ? 'ya' : 'direncanakan', 'status_ad_art' => $active ? 'ada_lengkap' : 'tidak_ada', ]); ($this->bumdesList)(); $html = ($this->bumdesAction)('kelembagaan.index', null, '1')->render(); $dom = new DOMDocument; $previous = libxml_use_internal_errors(true); $dom->loadHTML($html); libxml_clear_errors(); libxml_use_internal_errors($previous); $xpath = new DOMXPath($dom); expect($xpath->query('//form[@id="kelembagaanAktorForm" and @novalidate]')->length)->toBe(1); foreach (['nomor-dasar-hukum', 'tahun-dasar-hukum', 'nama-unit-komoditas', 'tahun-ad-art'] as $id) { $input = $xpath->query('//input[@id="'.$id.'"]')->item(0); expect($input->hasAttribute('disabled'))->toBe(! $active) ->and($input->hasAttribute('required'))->toBe($active); $label = $xpath->query('//span[@data-required-for="'.$id.'"]')->item(0); expect(str_contains($label->getAttribute('class'), 'd-none'))->toBe(! $active); for ($node = $input; $node instanceof DOMElement; $node = $node->parentNode) { expect(str_contains($node->getAttribute('class'), 'd-none'))->toBeFalse(); } } })->with([true, false]); test('bumdes notifications collect required errors above the cards and preserve old profile locations', function () { $request = Request::create('/bumdes', 'POST'); $this->app->instance('request', $request); try { ($this->bumdesAction)('store', $request); $this->fail('Expected profile validation errors.'); } catch (ValidationException $exception) { expect($exception->errors())->toHaveKeys(['nama', 'provinsi_id', 'kabupaten_id', 'kecamatan_id', 'desa_kelurahan_id']); $errors = (new ViewErrorBag)->put('default', new MessageBag($exception->errors())); view()->share('errors', $errors); } session()->flashInput(['provinsi_id' => 1, 'kabupaten_id' => 1, 'kecamatan_id' => 1, 'desa_kelurahan_id' => 1]); ($this->bumdesList)(); $controller = $this->bumdesAction; foreach ([$controller('create'), $controller('kelembagaan.index', null, '1'), $controller('operasional.index', null, '1')] as $view) { $html = $view->render(); expect(substr_count($html, 'id="bumdes-notifications"'))->toBe(1) ->and(strpos($html, 'id="bumdes-notifications"'))->toBeLessThan(strpos($html, 'id="bumdes-profile-summary"')); expect($html)->toContain('Nama harus diisi.', 'Provinsi wajib dipilih.', 'alert-danger alert-dismissible'); } $data = $controller('create')->getData(); expect($data['kabupatenOptions']->pluck('id')->all())->toBe([1]) ->and($data['kecamatanOptions']->pluck('id')->all())->toBe([1, 2]) ->and($data['desaOptions']->pluck('id')->all())->toBe([1]); }); test('bumdes creation saves profile status staff and active or inactive board members', function () { $request = Request::create('/bumdes', 'POST', [...$this->profileInput, 'redirect_fragment' => 'section-kepengurusan', 'status_keaktifan' => 'tidak_aktif_vakum', 'alasan_tidak_aktif' => 'Perbaikan kantor', 'pengurus_inti' => [ ['jabatan' => 'sekretaris', 'nama' => 'Budi', 'aktif' => '1'], ['jabatan' => 'bendahara', 'nama' => 'Siti', 'aktif' => '0'], ['jabatan' => 'penasihat_kepala_desa', 'nama' => '', 'aktif' => '0'], ], 'memiliki_staf' => '1', 'jumlah_staf' => 2, 'unit_usaha_staf' => 'Toko', ]); $this->app->instance('request', $request); $response = ($this->bumdesAction)('store', $request); $data = KelembagaanAktorBumdes::firstOrFail(); expect($response->getTargetUrl())->toBe(route('bumdes.edit', $data->pengurus_bumdes_id).'#section-kepengurusan') ->and($data->status_keaktifan?->value)->toBe('tidak_aktif_vakum') ->and($data->alasan_tidak_aktif)->toBe('Perbaikan kantor') ->and($data->jumlah_staf)->toBe(2) ->and(count($data->pengurus_inti))->toBe(2) ->and($data->pengurus_inti[0]['aktif'])->toBe('1') ->and($data->pengurus_inti[1]['aktif'])->toBe('0'); }); test('profile update redirects the upper save to the next card and ignores unknown targets', function (?string $fragment, string $expectedFragment) { $request = Request::create('/bumdes/1', 'PUT', [...$this->profileInput, 'redirect_fragment' => $fragment]); $this->app->instance('request', $request); $response = ($this->bumdesAction)('update', $request, '1'); expect($response->getTargetUrl())->toBe(route('bumdes.edit', 1).$expectedFragment) ->and(session('success'))->toBe('Data Profil berhasil disimpan.'); })->with([ ['section-kepengurusan', '#section-kepengurusan'], [null, ''], ['https://example.test', ''], ]); test('invalid moved profile fields prevent saving respondent changes', function () { $request = Request::create('/bumdes/1', 'PUT', [...$this->profileInput, 'status_keaktifan' => 'invalid', 'jumlah_staf' => -1, 'pengurus_inti' => [['jabatan' => 'sekretaris', 'nama' => 'Budi', 'aktif' => 'invalid']], ]); $this->app->instance('request', $request); try { ($this->bumdesAction)('update', $request, '1'); $this->fail('Expected profile validation errors.'); } catch (ValidationException $exception) { expect($exception->errors())->toHaveKeys(['status_keaktifan', 'jumlah_staf', 'pengurus_inti.0.aktif']); } expect(DB::table('bumdes_pengurus')->where('id', 1)->value('nama'))->toBe('Alpha') ->and(KelembagaanAktorBumdes::count())->toBe(0); }); test('capital addition details are optional when the answer is yes', function (?array $rows, int $expectedRows) { $request = Request::create('/bumdes/1/operasional', 'POST', [ 'pernah_penambahan_modal' => '1', 'penambahan_modal' => $rows, ]); $response = ($this->bumdesAction)('operasional.store', $request, '1'); expect($response->getTargetUrl())->toBe(route('bumdes.operasional.index', 1)); expect(KelembagaanAktorBumdes::firstOrFail()->penambahan_modal)->toHaveCount($expectedRows); })->with([ [null, 0], [[], 0], [[['tahun' => '', 'jumlah' => '']], 0], [[['tahun' => 2025, 'jumlah' => '']], 1], [[['tahun' => '', 'jumlah' => '1.000.000']], 1], ]); test('capital addition fields follow the selected answer without becoming required', function (bool $active) { KelembagaanAktorBumdes::create(['pengurus_bumdes_id' => 1, 'pernah_penambahan_modal' => $active]); ($this->bumdesList)(); $html = ($this->bumdesAction)('operasional.index', null, '1')->render(); $dom = new DOMDocument; $previous = libxml_use_internal_errors(true); $dom->loadHTML($html); libxml_clear_errors(); libxml_use_internal_errors($previous); $xpath = new DOMXPath($dom); $inputs = $xpath->query('//div[@id="penambahan-modal-wrapper"]//input'); expect($inputs->length)->toBe(6); foreach ($inputs as $input) { expect($input->hasAttribute('disabled'))->toBe(! $active) ->and($input->hasAttribute('required'))->toBeFalse(); } expect($xpath->query('//span[contains(@class, "penambahan-modal-required")]')->length)->toBe(0); })->with([true, false]); test('capital additions are ignored when the answer is no', function () { $request = Request::create('/bumdes/1/operasional', 'POST', [ 'pernah_penambahan_modal' => '0', 'penambahan_modal' => [['tahun' => '', 'jumlah' => '']], ]); $this->app->instance('request', $request); ($this->bumdesAction)('operasional.store', $request, '1'); expect(KelembagaanAktorBumdes::firstOrFail()->penambahan_modal)->toBeNull(); }); test('bumdes deletion records the actor and soft deletes the dependent section data', function () { $child = KelembagaanAktorBumdes::create(['pengurus_bumdes_id' => 1]); $response = ($this->bumdesAction)('destroy', null, '1'); expect($response->getTargetUrl())->toBe(route('bumdes.index')); $parent = PengurusBumdes::withTrashed()->findOrFail(1); $child = KelembagaanAktorBumdes::withTrashed()->findOrFail($child->id); expect($parent->trashed())->toBeTrue()->and((int) $parent->deleted_by)->toBe(7) ->and($child->trashed())->toBeTrue()->and((int) $child->deleted_by)->toBe(7) ->and(DB::table('master_desa_kelurahan')->where('id', 1)->value('deleted_at'))->toBeNull(); foreach ([$parent, $child] as $model) { $activity = DB::table('activity_log')->where('subject_type', $model::class) ->where('subject_id', $model->id)->where('event', 'deleted')->first(); expect($activity)->not->toBeNull(); expect((int) $activity->causer_id)->toBe(7); } }); test('enumerators cannot read change or delete another enumerators bumdes', function (string $action) { auth()->user()->role = 'enumerator'; try { ($this->bumdesAction)($action, Request::create('/bumdes/2', 'POST', $this->profileInput), '2'); $this->fail('Expected an ownership restriction.'); } catch (HttpException $exception) { expect($exception->getStatusCode())->toBe(404); } expect(DB::table('bumdes_pengurus')->where('id', 2)->value('nama'))->toBe('Beta') ->and(KelembagaanAktorBumdes::count())->toBe(0); })->with(['edit', 'update', 'destroy', 'kelembagaan.index', 'kelembagaan.store', 'operasional.index', 'operasional.store', 'rantai-nilai.index', 'rantai-nilai.store']); test('enumerators retain access to their own bumdes through the web routes', function () { auth()->user()->role = 'enumerator'; $this->get(route('bumdes.index'))->assertOk(); $this->get(route('bumdes.edit', 1))->assertOk(); $this->get(route('bumdes.kelembagaan-aktor.index', 1))->assertOk(); $this->get(route('bumdes.operasional.index', 1))->assertOk(); $this->get(route('bumdes.rantai-nilai.index', 1))->assertOk(); $this->get(route('bumdes.edit', 2))->assertNotFound(); }); test('workshop facilitators and coordinators do not gain bumdes survey access', function (string $role) { auth()->user()->role = $role; $this->get(route('bumdes.index'))->assertForbidden(); $this->put(route('bumdes.update', 1), $this->profileInput)->assertForbidden(); expect(DB::table('bumdes_pengurus')->where('id', 1)->value('nama'))->toBe('Alpha'); })->with(['fasilitator', 'koordinator']); test('incomplete profiles keep secondary tabs locked on the server', function () { DB::table('bumdes_pengurus')->where('id', 1)->update(['alamat' => null]); try { ($this->bumdesAction)('operasional.index', null, '1'); $this->fail('Expected an incomplete-profile restriction.'); } catch (HttpException $exception) { expect($exception->getStatusCode())->toBe(403); } }); test('profile creation rolls back if the dependent section cannot be saved', function () { Event::listen('eloquent.creating: '.KelembagaanAktorBumdes::class, function () { throw new RuntimeException('Failure during section persistence'); }); $request = Request::create('/bumdes', 'POST', $this->profileInput); expect(fn () => ($this->bumdesAction)('store', $request))->toThrow(RuntimeException::class); expect(DB::table('bumdes_pengurus')->count())->toBe(4)->and(KelembagaanAktorBumdes::count())->toBe(0); }); test('bumdes resource routes support the complete web workflow with model binding', function () { $this->get(route('bumdes.index'))->assertOk(); $this->get(route('bumdes.create'))->assertOk(); $this->post(route('bumdes.store'), [...$this->profileInput, 'redirect_fragment' => 'section-kepengurusan']) ->assertRedirect(); $pengurus = PengurusBumdes::latest('id')->firstOrFail(); $this->get(route('bumdes.show', $pengurus))->assertRedirect(route('bumdes.edit', $pengurus)); $this->get(route('bumdes.edit', $pengurus))->assertOk(); $this->post(route('bumdes.kelembagaan-aktor.store', $pengurus), [ 'status_badan_hukum' => 'sudah_berbadan_hukum', 'nomor_dasar_hukum' => 'SK-02', 'tahun_dasar_hukum' => 2025, 'unit_usaha' => ['simpan_pinjam'], ])->assertRedirect(route('bumdes.kelembagaan-aktor.index', $pengurus)); $this->get(route('bumdes.kelembagaan-aktor.index', $pengurus))->assertOk(); $this->post(route('bumdes.operasional.store', $pengurus), ['modal_awal' => '1.000.000']) ->assertRedirect(route('bumdes.operasional.index', $pengurus)); $this->get(route('bumdes.operasional.index', $pengurus))->assertOk(); $this->put(route('bumdes.update', $pengurus), [...$this->profileInput, 'nama' => 'Nama Baru']) ->assertRedirect(route('bumdes.edit', $pengurus)); expect($pengurus->refresh()->nama)->toBe('Nama Baru'); $this->delete(route('bumdes.destroy', $pengurus))->assertRedirect(route('bumdes.index')); $this->get(route('bumdes.edit', $pengurus))->assertNotFound(); expect(KelembagaanAktorBumdes::withTrashed()->where('pengurus_bumdes_id', $pengurus->id)->firstOrFail()->trashed())->toBeTrue(); }); test('web request validation keeps invalid optional capital values above the form', function () { $url = route('bumdes.operasional.index', 1); $this->from($url)->post(route('bumdes.operasional.store', 1), [ 'pernah_penambahan_modal' => '1', 'penambahan_modal' => [['tahun' => 'invalid', 'jumlah' => ['invalid']]], ])->assertRedirect($url)->assertSessionHasErrors(['penambahan_modal.0.tahun', 'penambahan_modal.0.jumlah']); $this->get($url)->assertOk()->assertSee('Tahun penambahan modal pada baris 1 harus berupa angka.') ->assertSee('data-has-errors="1"', false); }); test('productive assets use per-row availability ownership and condition selects with legacy defaults', function () { KelembagaanAktorBumdes::create([ 'pengurus_bumdes_id' => 1, 'status_kepemilikan_aset' => 'milik_bumdes_sendiri', 'aset_produktif' => [['aset' => 'bangunan_kantor', 'dipilih' => true, 'jumlah' => '1', 'kondisi' => 'Baik']], ]); ($this->bumdesList)(); $html = ($this->bumdesAction)('operasional.index', null, '1')->render(); $dom = new DOMDocument; $previous = libxml_use_internal_errors(true); $dom->loadHTML($html); libxml_clear_errors(); libxml_use_internal_errors($previous); $xpath = new DOMXPath($dom); expect($html)->toContain('Aset Produktif Milik BUMDes', 'Ada/Tidak', 'Status Kepemilikan'); expect($xpath->query('//div[@id="bumdes-aset-produktif"]//input[@type="radio"]')->length)->toBe(12); foreach (['bangunan_kantor' => true, 'gudang_penyimpanan' => false] as $asset => $active) { $selects = $xpath->query('//tr[@data-bumdes-aset="'.$asset.'"]//select'); expect($selects->length)->toBe(2); foreach ($selects as $select) { expect($select->hasAttribute('disabled'))->toBe(! $active) ->and($select->hasAttribute('required'))->toBe($active); } } expect($xpath->query('//tr[@data-bumdes-aset="bangunan_kantor"]//select[contains(@name, "status_kepemilikan_aset")]/option[@selected]')->item(0)->getAttribute('value'))->toBe('milik_bumdes_sendiri'); expect($xpath->query('//tr[@data-bumdes-aset="bangunan_kantor"]//select[contains(@name, "kondisi")]/option[@selected]')->item(0)->getAttribute('value'))->toBe('berfungsi'); expect($xpath->query('//input[@name="status_kepemilikan_aset"]')->length)->toBe(0); expect($xpath->query('//select[starts-with(@name, "infrastruktur_pascapanen") and contains(@name, "kondisi")]')->length)->toBe(6); }); test('productive assets save different ownership and condition for each available asset', function () { $request = Request::create('/bumdes/1/operasional', 'POST', ['aset_produktif' => [ ['aset' => 'bangunan_kantor', 'dipilih' => '1', 'jumlah' => '2', 'status_kepemilikan_aset' => 'milik_bumdes_sendiri', 'kondisi' => 'berfungsi'], ['aset' => 'gudang_penyimpanan', 'dipilih' => '1', 'jumlah' => '1', 'status_kepemilikan_aset' => 'sewa_pinjam_pihak_lain', 'kondisi' => 'perlu_perbaikan_evaluasi'], ['aset' => 'lahan_usaha', 'dipilih' => '0', 'jumlah' => ['invalid'], 'status_kepemilikan_aset' => 'invalid', 'kondisi' => ['invalid']], ]]); ($this->bumdesAction)('operasional.store', $request, '1'); $assets = KelembagaanAktorBumdes::firstOrFail()->aset_produktif; expect($assets)->toHaveCount(2) ->and($assets[0]['status_kepemilikan_aset'])->toBe('milik_bumdes_sendiri') ->and($assets[1]['status_kepemilikan_aset'])->toBe('sewa_pinjam_pihak_lain') ->and($assets[0]['kondisi'])->toBe('berfungsi') ->and($assets[1]['kondisi'])->toBe('perlu_perbaikan_evaluasi') ->and($assets[0]['jumlah'])->toBe('2'); }); test('available productive assets validate their required details and choice values', function (array $details, string $error) { $request = Request::create('/bumdes/1/operasional', 'POST', ['aset_produktif' => [ ['aset' => 'bangunan_kantor', 'dipilih' => '1', ...$details], ]]); try { ($this->bumdesAction)('operasional.store', $request, '1'); $this->fail('Expected productive asset validation.'); } catch (ValidationException $exception) { expect($exception->errors())->toHaveKey('aset_produktif.0.'.$error); } expect(KelembagaanAktorBumdes::count())->toBe(0); })->with([ [['kondisi' => 'berfungsi'], 'status_kepemilikan_aset'], [['status_kepemilikan_aset' => 'milik_bumdes_sendiri'], 'kondisi'], [['status_kepemilikan_aset' => 'invalid', 'kondisi' => 'berfungsi'], 'status_kepemilikan_aset'], [['status_kepemilikan_aset' => 'milik_bumdes_sendiri', 'kondisi' => 'invalid'], 'kondisi'], ]); test('legacy productive asset conditions and global ownership remain available without data loss', function () { $data = KelembagaanAktorBumdes::create([ 'pengurus_bumdes_id' => 1, 'status_kepemilikan_aset' => 'milik_desa_dipinjamkan_dikelola', 'aset_produktif' => [['aset' => 'bangunan_kantor', 'dipilih' => true, 'kondisi' => 'Perlu penggantian mesin']], ]); $request = Request::create('/bumdes/1/operasional', 'POST', ['aset_produktif' => [ ['aset' => 'bangunan_kantor', 'dipilih' => '1', 'kondisi' => 'Perlu penggantian mesin'], ]]); ($this->bumdesAction)('operasional.store', $request, '1'); $data->refresh(); expect($data->aset_produktif[0]['kondisi'])->toBe('Perlu penggantian mesin') ->and($data->aset_produktif[0]['status_kepemilikan_aset'])->toBe('milik_desa_dipinjamkan_dikelola') ->and($data->status_kepemilikan_aset?->value)->toBe('milik_desa_dipinjamkan_dikelola'); $request = Request::create('/bumdes/1/operasional', 'POST', ['aset_produktif' => [ ['aset' => 'bangunan_kantor', 'dipilih' => '0'], ]]); ($this->bumdesAction)('operasional.store', $request, '1'); expect($data->refresh()->aset_produktif)->toBe([]); }); test('postharvest infrastructure uses availability radios and gated numeric fields like productive assets', function () { KelembagaanAktorBumdes::create([ 'pengurus_bumdes_id' => 1, 'infrastruktur_pascapanen' => [['infrastruktur' => 'timbangan', 'dipilih' => true, 'jumlah' => '2', 'kondisi' => 'baik_berfungsi']], ]); ($this->bumdesList)(); $html = ($this->bumdesAction)('operasional.index', null, '1')->render(); $dom = new DOMDocument; $previous = libxml_use_internal_errors(true); $dom->loadHTML($html); libxml_clear_errors(); libxml_use_internal_errors($previous); $xpath = new DOMXPath($dom); expect($xpath->query('//div[@id="bumdes-infrastruktur-pascapanen"]//input[@type="radio"]')->length)->toBe(12) ->and($xpath->query('//div[@id="bumdes-infrastruktur-pascapanen"]//input[@type="checkbox"]')->length)->toBe(0); foreach (['timbangan' => true, 'lainnya' => false] as $key => $active) { $row = '//tr[@data-bumdes-infrastruktur="'.$key.'"]'; $quantity = $xpath->query($row.'//input[contains(@name, "[jumlah]")]')->item(0); $condition = $xpath->query($row.'//select')->item(0); expect($quantity->getAttribute('type'))->toBe('number') ->and($quantity->getAttribute('min'))->toBe('0') ->and($quantity->hasAttribute('disabled'))->toBe(! $active) ->and($quantity->hasAttribute('required'))->toBeFalse() ->and($condition->hasAttribute('disabled'))->toBe(! $active) ->and($condition->hasAttribute('required'))->toBe($active); } expect($xpath->query('//tr[@data-bumdes-infrastruktur="timbangan"]//input[contains(@name, "[jumlah]")]')->item(0)->getAttribute('value'))->toBe('2') ->and($xpath->query('//tr[@data-bumdes-infrastruktur="timbangan"]//option[@selected]')->item(0)->getAttribute('value'))->toBe('baik_berfungsi') ->and($xpath->query('//div[@id="bumdes-aset-produktif"]//input[@type="number" and @min="0"]')->length)->toBe(6); }); test('postharvest infrastructure saves available rows and removes unavailable stale details', function () { $url = route('bumdes.operasional.store', 1); $this->post($url, [ 'infrastruktur_pascapanen' => [ ['infrastruktur' => 'timbangan', 'dipilih' => '1', 'jumlah' => '0', 'kondisi' => 'baik_berfungsi'], ['infrastruktur' => 'lainnya', 'dipilih' => '1', 'jumlah' => '3', 'kondisi' => 'rusak_tidak_terpakai'], ['infrastruktur' => 'alat_sortasi', 'dipilih' => '0', 'jumlah' => ['invalid'], 'kondisi' => ['invalid']], ], 'infrastruktur_pascapanen_lainnya' => 'Meja pengering', ])->assertSessionHasNoErrors()->assertRedirect(route('bumdes.operasional.index', 1)); $data = KelembagaanAktorBumdes::firstOrFail(); expect($data->infrastruktur_pascapanen)->toHaveCount(2) ->and($data->infrastruktur_pascapanen[0]['jumlah'])->toBe('0') ->and($data->infrastruktur_pascapanen[1]['kondisi'])->toBe('rusak_tidak_terpakai') ->and($data->infrastruktur_pascapanen_lainnya)->toBe('Meja pengering'); $this->post($url, [ 'infrastruktur_pascapanen' => [['infrastruktur' => 'lainnya', 'dipilih' => '0', 'jumlah' => '-1', 'kondisi' => 'invalid']], 'infrastruktur_pascapanen_lainnya' => 'Meja pengering', ])->assertSessionHasNoErrors(); expect($data->refresh()->infrastruktur_pascapanen)->toBe([]) ->and($data->infrastruktur_pascapanen_lainnya)->toBeNull(); }); test('available infrastructure validates required details and condition choices', function (array $row, string $error) { $this->post(route('bumdes.operasional.store', 1), ['infrastruktur_pascapanen' => [$row]]) ->assertSessionHasErrors($error); expect(KelembagaanAktorBumdes::count())->toBe(0); })->with([ [['infrastruktur' => 'timbangan', 'dipilih' => '1'], 'infrastruktur_pascapanen.0.kondisi'], [['infrastruktur' => 'timbangan', 'dipilih' => '1', 'kondisi' => 'invalid'], 'infrastruktur_pascapanen.0.kondisi'], [['infrastruktur' => 'lainnya', 'dipilih' => '1', 'kondisi' => 'baik_berfungsi'], 'infrastruktur_pascapanen_lainnya'], ]); test('inventory quantities reject negative fractional and nonnumeric values', function (string $field, string $quantity) { $row = $field === 'aset_produktif' ? ['aset' => 'bangunan_kantor', 'status_kepemilikan_aset' => 'milik_bumdes_sendiri', 'kondisi' => 'berfungsi'] : ['infrastruktur' => 'timbangan', 'kondisi' => 'baik_berfungsi']; $this->post(route('bumdes.operasional.store', 1), [$field => [[...$row, 'dipilih' => '1', 'jumlah' => $quantity]]]) ->assertSessionHasErrors($field.'.0.jumlah'); expect(KelembagaanAktorBumdes::count())->toBe(0); })->with(['aset_produktif', 'infrastruktur_pascapanen'])->with(['-1', '1.5', 'dua']); test('saving each bumdes tab preserves the other tabs data and redirects to the saved tab', function () { $controller = $this->bumdesAction; $save = function (string $method, array $input) use ($controller) { $request = Request::create('/bumdes/1', 'POST', $input); $request->setLaravelSession(app('session')->driver()); $this->app->instance('request', $request); return $controller($method, $request, '1'); }; $save('update', [...$this->profileInput, 'status_keaktifan' => 'tidak_aktif_vakum', 'alasan_tidak_aktif' => 'Kekurangan modal', 'memiliki_staf' => '1', 'jumlah_staf' => 3, 'unit_usaha_staf' => 'Toko', ]); $response = $save('operasional.store', [ 'status_keaktifan' => 'tidak_aktif_vakum', 'alasan_tidak_aktif' => 'Kekurangan modal', 'memiliki_staf' => '1', 'jumlah_staf' => 3, 'unit_usaha_staf' => 'Toko', 'modal_awal' => '10.000.000', 'tahun_modal_awal' => 2024, 'pernah_penambahan_modal' => '1', 'penambahan_modal' => [['tahun' => 2025, 'jumlah' => '1.000.000']], ]); expect($response->getTargetUrl())->toBe(route('bumdes.operasional.index', 1)); $response = $save('kelembagaan.store', [ 'status_badan_hukum' => 'sudah_berbadan_hukum', 'nomor_dasar_hukum' => 'SK-01', 'tahun_dasar_hukum' => 2024, 'unit_usaha' => ['simpan_pinjam', 'jasa_lainnya'], 'unit_usaha_jasa_lainnya' => 'Pengiriman', 'status_unit_komoditas' => 'ya', 'nama_unit_komoditas' => 'Kopi', 'status_ad_art' => 'ada_lengkap', 'tahun_ad_art' => 2025, 'status_keaktifan' => 'aktif_penuh', ]); expect($response->getTargetUrl())->toBe(route('bumdes.kelembagaan-aktor.index', 1)); $data = KelembagaanAktorBumdes::firstOrFail(); expect($data->status_keaktifan?->value)->toBe('tidak_aktif_vakum') ->and($data->alasan_tidak_aktif)->toBe('Kekurangan modal') ->and($data->jumlah_staf)->toBe(3) ->and($data->modal_awal)->toBe('10000000.00') ->and($data->penambahan_modal[0]['jumlah'])->toBe('1000000'); $save('operasional.store', ['status_keaktifan' => 'aktif_penuh', 'status_badan_hukum' => 'tidak_tahu']); expect(KelembagaanAktorBumdes::firstOrFail()->status_keaktifan?->value)->toBe('tidak_aktif_vakum'); $save('update', [...$this->profileInput, 'status_keaktifan' => 'aktif_penuh', 'status_badan_hukum' => 'tidak_tahu']); $data->refresh(); expect($data->status_badan_hukum?->value)->toBe('sudah_berbadan_hukum') ->and($data->nomor_dasar_hukum)->toBe('SK-01') ->and($data->unit_usaha->map(fn ($value) => $value->value)->all())->toBe(['simpan_pinjam', 'jasa_lainnya']) ->and($data->nama_unit_komoditas)->toBe('Kopi') ->and($data->tahun_ad_art)->toBe(2025) ->and($data->alasan_tidak_aktif)->toBeNull(); $save('kelembagaan.store', ['unit_usaha' => [], 'status_ad_art' => 'tidak_ada', 'status_unit_komoditas' => 'direncanakan']); $data->refresh(); expect($data->unit_usaha->map(fn ($value) => $value->value)->all())->toBe([]) ->and($data->unit_usaha_jasa_lainnya)->toBeNull() ->and($data->nama_unit_komoditas)->toBeNull() ->and($data->tahun_ad_art)->toBeNull() ->and($data->status_keaktifan?->value)->toBe('aktif_penuh'); }); test('ecosystem support is included in the operational form without question numbers or a separate tab', function () { $data = KelembagaanAktorBumdes::create([ 'pengurus_bumdes_id' => 1, 'ada_alokasi_desa' => false, 'dukungan_desa' => ['pendampingan_teknis', 'lainnya'], 'dukungan_desa_lainnya' => 'Pelatihan pemasaran', 'keterlibatan_koordinasi_desa' => 'kadang', 'kemitraan_eksternal' => ['ngo', 'lainnya'], 'kemitraan_eksternal_lainnya' => 'Perguruan tinggi', ]); $response = $this->get(route('bumdes.operasional.index', 1))->assertOk(); $response->assertSee('Dukungan Ekosistem')->assertSee('Alokasi APBDes/Dana Desa Tahun Berjalan') ->assertSee('Dukungan Lain dari Pemerintah Desa') ->assertSee('Keterlibatan dalam Rapat Desa Terkait Komoditas') ->assertSee('Kemitraan Eksternal'); $dom = new DOMDocument; $previous = libxml_use_internal_errors(true); $dom->loadHTML($response->getContent()); libxml_clear_errors(); libxml_use_internal_errors($previous); $xpath = new DOMXPath($dom); expect($xpath->query('//input[@id="alokasi-desa-tidak"]')->item(0)->hasAttribute('checked'))->toBeTrue() ->and($xpath->query('//input[@id="jumlah-alokasi-desa"]')->item(0)->hasAttribute('disabled'))->toBeTrue() ->and($xpath->query('//input[@name="dukungan_desa[]"]')->length)->toBe(5) ->and($xpath->query('//input[@name="kemitraan_eksternal[]"]')->length)->toBe(6) ->and($xpath->query('//input[@name="keterlibatan_koordinasi_desa"]')->length)->toBe(3); expect($xpath->query('//a[contains(@class, "nav-link") and contains(., "Dukungan Ekosistem")]')->length)->toBe(0) ->and($xpath->query('//form[@id="operasionalBumdesForm"]//input[@name="modal_awal"]')->length)->toBe(1) ->and($xpath->query('//form[@id="operasionalBumdesForm"]//input[@name="jumlah_alokasi_desa"]')->length)->toBe(1) ->and($xpath->query('//form[@id="operasionalBumdesForm"]//button[@type="submit"]')->length)->toBe(7); foreach ($xpath->query('//form[@id="operasionalBumdesForm"]//legend') as $legend) { expect(trim($legend->textContent))->not->toMatch('/^\d+\./'); } foreach (['dukungan-desa-lainnya-detail', 'kemitraan-eksternal-lainnya-detail'] as $id) { $input = $xpath->query('//input[@id="'.$id.'"]')->item(0); expect($input->hasAttribute('disabled'))->toBeFalse()->and($input->hasAttribute('required'))->toBeTrue(); } expect($data->dukungan_desa->first())->toBe(DukunganDesaBumdes::PENDAMPINGAN_TEKNIS) ->and($data->keterlibatan_koordinasi_desa)->toBe(KeterlibatanKoordinasiBumdes::KADANG); }); test('operational form saves capital assets and ecosystem answers together without overwriting other tabs', function () { $data = KelembagaanAktorBumdes::create([ 'pengurus_bumdes_id' => 1, 'status_badan_hukum' => 'tidak_tahu', 'status_keaktifan' => 'aktif_penuh', 'modal_awal' => 2000000, ]); $url = route('bumdes.operasional.index', 1); $this->post(route('bumdes.operasional.store', 1), [ 'ada_alokasi_desa' => '1', 'jumlah_alokasi_desa' => '10.000.000', 'dukungan_desa' => ['pendampingan_teknis', 'regulasi_perdes', 'lainnya'], 'dukungan_desa_lainnya' => 'Pelatihan pemasaran', 'keterlibatan_koordinasi_desa' => 'selalu', 'kemitraan_eksternal' => ['ngo', 'swasta_offtaker', 'lainnya'], 'kemitraan_eksternal_lainnya' => 'Perguruan tinggi', 'modal_awal' => '999', 'nama' => 'Nama tidak boleh berubah', 'aset_produktif' => [['aset' => 'bangunan_kantor', 'dipilih' => '1', 'jumlah' => 2, 'kondisi' => 'berfungsi', 'status_kepemilikan_aset' => 'milik_bumdes_sendiri']], ])->assertRedirect($url)->assertSessionHasNoErrors()->assertSessionHas('success', 'Data Operasional berhasil disimpan.'); $data->refresh(); expect($data->ada_alokasi_desa)->toBeTrue() ->and($data->jumlah_alokasi_desa)->toBe('10000000.00') ->and($data->dukungan_desa->map->value->all())->toBe(['pendampingan_teknis', 'regulasi_perdes', 'lainnya']) ->and($data->dukungan_desa_lainnya)->toBe('Pelatihan pemasaran') ->and($data->kemitraan_eksternal->map->value->all())->toBe(['ngo', 'swasta_offtaker', 'lainnya']) ->and($data->kemitraan_eksternal_lainnya)->toBe('Perguruan tinggi') ->and($data->keterlibatan_koordinasi_desa->value)->toBe('selalu') ->and($data->modal_awal)->toBe('999.00') ->and($data->aset_produktif[0]['jumlah'])->toBe(2) ->and($data->status_badan_hukum->value)->toBe('tidak_tahu') ->and($data->status_keaktifan->value)->toBe('aktif_penuh') ->and($data->created_by)->toBe(7) ->and($data->updated_by)->toBe(7) ->and(PengurusBumdes::findOrFail(1)->nama)->toBe('Alpha'); $this->get($url)->assertOk()->assertSee('value="10.000.000"', false)->assertSee('Pelatihan pemasaran'); $this->put(route('bumdes.update', 1), $this->profileInput)->assertSessionHasNoErrors(); $this->post(route('bumdes.kelembagaan-aktor.store', 1), ['status_badan_hukum' => 'tidak_tahu'])->assertSessionHasNoErrors(); expect($data->refresh()->jumlah_alokasi_desa)->toBe('10000000.00') ->and($data->dukungan_desa_lainnya)->toBe('Pelatihan pemasaran') ->and($data->kemitraan_eksternal_lainnya)->toBe('Perguruan tinggi'); }); test('ecosystem support clears disabled answers and allows unanswered questions', function () { $data = KelembagaanAktorBumdes::create([ 'pengurus_bumdes_id' => 1, 'ada_alokasi_desa' => true, 'jumlah_alokasi_desa' => 1000, 'dukungan_desa' => ['lainnya'], 'dukungan_desa_lainnya' => 'Pelatihan', 'kemitraan_eksternal' => ['lainnya'], 'kemitraan_eksternal_lainnya' => 'Universitas', ]); $url = route('bumdes.operasional.store', 1); $this->post($url, [ 'ada_alokasi_desa' => '0', 'jumlah_alokasi_desa' => ['invalid'], 'dukungan_desa' => ['tidak_ada'], 'dukungan_desa_lainnya' => ['invalid'], 'kemitraan_eksternal' => ['belum_ada'], 'kemitraan_eksternal_lainnya' => ['invalid'], ])->assertSessionHasNoErrors(); expect($data->refresh()->ada_alokasi_desa)->toBeFalse() ->and($data->jumlah_alokasi_desa)->toBeNull() ->and($data->dukungan_desa_lainnya)->toBeNull() ->and($data->kemitraan_eksternal_lainnya)->toBeNull(); $this->post($url, [])->assertSessionHasNoErrors(); expect($data->refresh()->ada_alokasi_desa)->toBeNull() ->and($data->dukungan_desa->all())->toBe([]) ->and($data->kemitraan_eksternal->all())->toBe([]); }); test('ecosystem support validates conditional details choices and exclusive answers', function (array $input, string $field) { $url = route('bumdes.operasional.index', 1); $this->from($url)->post(route('bumdes.operasional.store', 1), $input) ->assertRedirect($url)->assertSessionHasErrors($field)->assertSessionHasInput(array_key_first($input)); $response = $this->get($url)->assertOk()->assertSee('data-has-errors="1"', false); expect(strpos($response->getContent(), 'id="bumdes-notifications"'))->toBeLessThan(strpos($response->getContent(), 'id="operasionalBumdesForm"')); expect(KelembagaanAktorBumdes::count())->toBe(0); })->with([ [['ada_alokasi_desa' => '1'], 'jumlah_alokasi_desa'], [['ada_alokasi_desa' => '1', 'jumlah_alokasi_desa' => '-1'], 'jumlah_alokasi_desa'], [['ada_alokasi_desa' => '1', 'jumlah_alokasi_desa' => '1.5'], 'jumlah_alokasi_desa'], [['ada_alokasi_desa' => '1', 'jumlah_alokasi_desa' => '10abc'], 'jumlah_alokasi_desa'], [['ada_alokasi_desa' => '1', 'jumlah_alokasi_desa' => '10000000000000000'], 'jumlah_alokasi_desa'], [['ada_alokasi_desa' => 'invalid'], 'ada_alokasi_desa'], [['ada_alokasi_desa' => ['invalid']], 'ada_alokasi_desa'], [['ada_alokasi_desa' => '1', 'jumlah_alokasi_desa' => ['invalid']], 'jumlah_alokasi_desa'], [['dukungan_desa' => ['lainnya']], 'dukungan_desa_lainnya'], [['kemitraan_eksternal' => ['lainnya']], 'kemitraan_eksternal_lainnya'], [['dukungan_desa' => ['lainnya'], 'dukungan_desa_lainnya' => ['invalid']], 'dukungan_desa_lainnya'], [['kemitraan_eksternal' => ['lainnya'], 'kemitraan_eksternal_lainnya' => ['invalid']], 'kemitraan_eksternal_lainnya'], [['dukungan_desa' => ['tidak_ada', 'pendampingan_teknis']], 'dukungan_desa'], [['kemitraan_eksternal' => ['belum_ada', 'ngo']], 'kemitraan_eksternal'], [['dukungan_desa' => ['invalid']], 'dukungan_desa.0'], [['kemitraan_eksternal' => ['invalid']], 'kemitraan_eksternal.0'], [['dukungan_desa' => 'pendampingan_teknis'], 'dukungan_desa'], [['kemitraan_eksternal' => ['ngo', 'ngo']], 'kemitraan_eksternal.0'], [['keterlibatan_koordinasi_desa' => 'invalid'], 'keterlibatan_koordinasi_desa'], ]); test('invalid ecosystem answers prevent partial updates to operational data', function () { $data = KelembagaanAktorBumdes::create([ 'pengurus_bumdes_id' => 1, 'modal_awal' => 2000000, 'dukungan_desa' => ['pendampingan_teknis'], ]); $this->from(route('bumdes.operasional.index', 1))->post(route('bumdes.operasional.store', 1), [ 'modal_awal' => '5000000', 'ada_alokasi_desa' => '1', 'dukungan_desa' => ['regulasi_perdes'], ])->assertSessionHasErrors('jumlah_alokasi_desa'); expect($data->refresh()->modal_awal)->toBe('2000000.00') ->and($data->dukungan_desa->map->value->all())->toBe(['pendampingan_teknis']); $this->get(route('bumdes.operasional.index', 1))->assertOk()->assertSee('value="5.000.000"', false); }); test('ecosystem support stays locked for incomplete profiles', function () { DB::table('bumdes_pengurus')->where('id', 1)->update(['alamat' => null]); $this->get(route('bumdes.operasional.index', 1))->assertForbidden(); $this->post(route('bumdes.operasional.store', 1), [])->assertForbidden(); }); test('ecosystem allocation accepts zero without treating it as an empty answer', function () { $this->post(route('bumdes.operasional.store', 1), ['ada_alokasi_desa' => '1', 'jumlah_alokasi_desa' => '0']) ->assertSessionHasNoErrors(); expect(KelembagaanAktorBumdes::firstOrFail()->jumlah_alokasi_desa)->toBe('0.00'); }); test('ecosystem support migration can roll back without losing existing tab data', function () { $data = KelembagaanAktorBumdes::create(['pengurus_bumdes_id' => 1, 'modal_awal' => 2000000]); $migration = require database_path('migrations/2026_11_04_000001_add_dukungan_ekosistem_to_bumdes_kelembagaan_aktor_table.php'); $migration->down(); expect(Schema::hasColumn('bumdes_kelembagaan_aktor', 'ada_alokasi_desa'))->toBeFalse() ->and(DB::table('bumdes_kelembagaan_aktor')->where('id', $data->id)->value('modal_awal'))->toEqual(2000000); $migration->up(); expect(Schema::hasColumn('bumdes_kelembagaan_aktor', 'ada_alokasi_desa'))->toBeTrue() ->and(DB::table('bumdes_kelembagaan_aktor')->where('id', $data->id)->value('modal_awal'))->toEqual(2000000); }); test('operational management questions are grouped into separate cards with save and back buttons', function () { $response = $this->get(route('bumdes.operasional.index', 1))->assertOk(); $dom = new DOMDocument; $previous = libxml_use_internal_errors(true); $dom->loadHTML($response->getContent()); libxml_clear_errors(); libxml_use_internal_errors($previous); $xpath = new DOMXPath($dom); $cards = $xpath->query('//form[@id="operasionalBumdesForm"]/div[contains(@class, "card")]'); expect($cards->length)->toBe(7); foreach ($cards as $card) { expect($xpath->query('.//button[@type="submit"]', $card)->length)->toBe(1) ->and($xpath->query('.//a[normalize-space(.)="Kembali"]', $card)->length)->toBe(1); } foreach (['kapasitas-keuangan', 'jangkauan-layanan', 'layanan-petani', 'rantai-nilai', 'hambatan-usaha'] as $section) { expect($xpath->query('//div[@id="section-'.$section.'"]')->length)->toBe(1); } foreach (['sifat_audit[]', 'pelaksana_audit', 'tahun_audit', 'tahun_laporan_musdes', 'nominal_kontribusi_pades', 'desa_lain_dilayani', 'jumlah_petani_desa_lain', 'nama_offtaker', 'tahun_mulai_offtaker', 'alasan_berhenti_offtaker'] as $field) { foreach ($xpath->query('//*[@name="'.$field.'"]') as $input) { expect($input->hasAttribute('disabled'))->toBeTrue(); } } foreach (['jumlah_petani_desa_sendiri', 'jumlah_petani_desa_lain'] as $field) { $input = $xpath->query('//input[@name="'.$field.'"]')->item(0); expect($input->getAttribute('type'))->toBe('number')->and($input->getAttribute('min'))->toBe('0'); } foreach ($xpath->query('//form[@id="operasionalBumdesForm"]//legend') as $legend) { expect(trim($legend->textContent))->not->toMatch('/^\d+[.)]/'); } }); test('operational management answers are stored with enums and restored without changing other tabs', function () { $data = KelembagaanAktorBumdes::create(['pengurus_bumdes_id' => 1, 'status_badan_hukum' => 'tidak_tahu', 'status_keaktifan' => 'aktif_penuh']); $input = [ 'modal_awal' => '1.000.000', 'status_pembukuan' => 'rutin_tertib', 'format_pembukuan' => 'digital_excel', 'pernah_audit' => '1', 'sifat_audit' => ['internal', 'eksternal'], 'pelaksana_audit' => 'Inspektorat', 'tahun_audit' => 2025, 'pertanggungjawaban_musdes' => 'tahunan', 'tahun_laporan_musdes' => 2025, 'kondisi_keuangan' => 'untung_surplus', 'kontribusi_pades' => 'tidak_rutin', 'nominal_kontribusi_pades' => '2.500.000', 'cakupan_layanan' => 'desa_sendiri_dan_lain', 'desa_lain_dilayani' => 'Desa B, Desa C', 'jumlah_petani_desa_sendiri' => 25, 'jumlah_petani_desa_lain' => 0, 'pencatatan_petani' => 'rapi', 'layanan_petani' => ['simpan_pinjam', 'pelatihan', 'lainnya'], 'layanan_petani_lainnya' => 'Pengiriman hasil panen', 'peran_rantai_nilai' => 'mengolah_sendiri', 'pengalaman_offtaker' => 'berjalan', 'nama_offtaker' => 'Mitra Kopi', 'tahun_mulai_offtaker' => 2024, 'hambatan_usaha' => ['modal', 'akses_pasar', 'lainnya'], 'hambatan_usaha_lainnya' => 'Kendala logistik', ]; $this->post(route('bumdes.operasional.store', 1), $input) ->assertRedirect(route('bumdes.operasional.index', 1))->assertSessionHasNoErrors() ->assertSessionHas('success', 'Data Operasional berhasil disimpan.'); $data->refresh(); expect($data->status_pembukuan)->toBe(StatusPembukuanBumdes::RUTIN_TERTIB) ->and($data->sifat_audit->map->value->all())->toBe(['internal', 'eksternal']) ->and($data->pelaksana_audit)->toBe('Inspektorat') ->and($data->tahun_audit)->toBe(2025) ->and($data->tahun_laporan_musdes)->toBe(2025) ->and($data->nominal_kontribusi_pades)->toBe('2500000.00') ->and($data->jumlah_petani_desa_lain)->toBe(0) ->and($data->desa_lain_dilayani)->toBe('Desa B, Desa C') ->and($data->layanan_petani->map->value->all())->toBe(['simpan_pinjam', 'pelatihan', 'lainnya']) ->and($data->layanan_petani_lainnya)->toBe('Pengiriman hasil panen') ->and($data->nama_offtaker)->toBe('Mitra Kopi') ->and($data->tahun_mulai_offtaker)->toBe(2024) ->and($data->hambatan_usaha->map->value->all())->toBe(['modal', 'akses_pasar', 'lainnya']) ->and($data->hambatan_usaha_lainnya)->toBe('Kendala logistik') ->and($data->status_badan_hukum->value)->toBe('tidak_tahu') ->and($data->status_keaktifan->value)->toBe('aktif_penuh'); $this->get(route('bumdes.operasional.index', 1))->assertOk()->assertSee('Inspektorat')->assertSee('value="2.500.000"', false)->assertSee('Mitra Kopi'); $this->put(route('bumdes.update', 1), $this->profileInput)->assertSessionHasNoErrors(); $this->post(route('bumdes.kelembagaan-aktor.store', 1), ['status_badan_hukum' => 'tidak_tahu'])->assertSessionHasNoErrors(); expect($data->refresh()->nama_offtaker)->toBe('Mitra Kopi')->and($data->tahun_audit)->toBe(2025); }); test('operational management skips disabled details and clears prior branch data', function () { $data = KelembagaanAktorBumdes::create([ 'pengurus_bumdes_id' => 1, 'pernah_audit' => true, 'sifat_audit' => ['internal'], 'pelaksana_audit' => 'Pengawas', 'tahun_audit' => 2025, 'pertanggungjawaban_musdes' => 'tahunan', 'tahun_laporan_musdes' => 2025, 'kontribusi_pades' => 'rutin', 'nominal_kontribusi_pades' => 1000000, 'cakupan_layanan' => 'desa_sendiri_dan_lain', 'desa_lain_dilayani' => 'Desa B', 'jumlah_petani_desa_lain' => 12, 'pengalaman_offtaker' => 'berjalan', 'nama_offtaker' => 'Mitra', 'tahun_mulai_offtaker' => 2024, 'layanan_petani' => ['lainnya'], 'layanan_petani_lainnya' => 'Pengiriman', 'hambatan_usaha' => ['lainnya'], 'hambatan_usaha_lainnya' => 'Logistik', ]); $this->post(route('bumdes.operasional.store', 1), [ 'pernah_audit' => '0', 'sifat_audit' => 'invalid', 'pelaksana_audit' => ['invalid'], 'tahun_audit' => 'invalid', 'pertanggungjawaban_musdes' => 'belum_pernah', 'tahun_laporan_musdes' => 'invalid', 'kontribusi_pades' => 'belum_pernah', 'nominal_kontribusi_pades' => ['invalid'], 'cakupan_layanan' => 'desa_sendiri', 'desa_lain_dilayani' => ['invalid'], 'jumlah_petani_desa_lain' => 'invalid', 'pengalaman_offtaker' => 'belum_pernah', 'nama_offtaker' => ['invalid'], 'tahun_mulai_offtaker' => 'invalid', 'alasan_berhenti_offtaker' => ['invalid'], 'layanan_petani' => ['belum_ada'], 'layanan_petani_lainnya' => ['invalid'], 'hambatan_usaha' => ['modal'], 'hambatan_usaha_lainnya' => ['invalid'], ])->assertSessionHasNoErrors(); $data->refresh(); expect($data->sifat_audit->all())->toBe([]); foreach (['pelaksana_audit', 'tahun_audit', 'tahun_laporan_musdes', 'nominal_kontribusi_pades', 'desa_lain_dilayani', 'jumlah_petani_desa_lain', 'nama_offtaker', 'tahun_mulai_offtaker', 'alasan_berhenti_offtaker', 'layanan_petani_lainnya', 'hambatan_usaha_lainnya'] as $field) { expect($data->$field)->toBeNull(); } $this->post(route('bumdes.operasional.store', 1), ['pengalaman_offtaker' => 'berhenti', 'alasan_berhenti_offtaker' => 'Harga tidak sesuai'])->assertSessionHasNoErrors(); expect($data->refresh()->alasan_berhenti_offtaker)->toBe('Harga tidak sesuai'); }); test('operational management rejects missing dependent answers and invalid values without partial saves', function (array $input, array $errors) { $data = KelembagaanAktorBumdes::create(['pengurus_bumdes_id' => 1, 'modal_awal' => 2000000]); $url = route('bumdes.operasional.index', 1); $this->from($url)->post(route('bumdes.operasional.store', 1), [...$input, 'modal_awal' => '5000000']) ->assertRedirect($url)->assertSessionHasErrors($errors); expect($data->refresh()->modal_awal)->toBe('2000000.00'); $this->get($url)->assertOk()->assertSee('data-has-errors="1"', false); })->with([ [['pernah_audit' => '1'], ['sifat_audit', 'pelaksana_audit', 'tahun_audit']], [['pernah_audit' => '1', 'sifat_audit' => ['invalid'], 'pelaksana_audit' => 'Pengawas', 'tahun_audit' => 2025], ['sifat_audit.0']], [['pertanggungjawaban_musdes' => 'tahunan'], ['tahun_laporan_musdes']], [['pertanggungjawaban_musdes' => 'tahunan', 'tahun_laporan_musdes' => 1899], ['tahun_laporan_musdes']], [['kontribusi_pades' => 'rutin'], ['nominal_kontribusi_pades']], [['kontribusi_pades' => 'tidak_rutin', 'nominal_kontribusi_pades' => '-1'], ['nominal_kontribusi_pades']], [['kontribusi_pades' => 'rutin', 'nominal_kontribusi_pades' => '1.5'], ['nominal_kontribusi_pades']], [['kontribusi_pades' => 'rutin', 'nominal_kontribusi_pades' => ['invalid']], ['nominal_kontribusi_pades']], [['cakupan_layanan' => 'desa_sendiri_dan_lain'], ['desa_lain_dilayani', 'jumlah_petani_desa_lain']], [['jumlah_petani_desa_sendiri' => -1], ['jumlah_petani_desa_sendiri']], [['jumlah_petani_desa_sendiri' => '1.5'], ['jumlah_petani_desa_sendiri']], [['pengalaman_offtaker' => 'berjalan'], ['nama_offtaker', 'tahun_mulai_offtaker']], [['pengalaman_offtaker' => 'berhenti'], ['alasan_berhenti_offtaker']], [['layanan_petani' => ['lainnya']], ['layanan_petani_lainnya']], [['layanan_petani' => ['belum_ada', 'pelatihan']], ['layanan_petani']], [['hambatan_usaha' => ['lainnya']], ['hambatan_usaha_lainnya']], [['hambatan_usaha' => ['modal', 'modal']], ['hambatan_usaha.0']], [['layanan_petani' => ['lainnya'], 'layanan_petani_lainnya' => ['invalid']], ['layanan_petani_lainnya']], ]); test('operational fixed choices reject unknown values', function (string $field) { $this->post(route('bumdes.operasional.store', 1), [$field => 'invalid'])->assertSessionHasErrors($field); expect(KelembagaanAktorBumdes::count())->toBe(0); })->with(['status_pembukuan', 'format_pembukuan', 'pernah_audit', 'pertanggungjawaban_musdes', 'kondisi_keuangan', 'kontribusi_pades', 'cakupan_layanan', 'pencatatan_petani', 'peran_rantai_nilai', 'pengalaman_offtaker', 'layanan_petani', 'hambatan_usaha']); test('operational historical years cannot be in the future', function () { $future = now()->year + 1; $this->post(route('bumdes.operasional.store', 1), [ 'pernah_audit' => '1', 'sifat_audit' => ['internal'], 'pelaksana_audit' => 'Pengawas', 'tahun_audit' => $future, 'pertanggungjawaban_musdes' => 'tahunan', 'tahun_laporan_musdes' => $future, 'pengalaman_offtaker' => 'berjalan', 'nama_offtaker' => 'Mitra', 'tahun_mulai_offtaker' => $future, ])->assertSessionHasErrors(['tahun_audit', 'tahun_laporan_musdes', 'tahun_mulai_offtaker']); expect(KelembagaanAktorBumdes::count())->toBe(0); }); test('management migration rolls back only its new columns', function () { $data = KelembagaanAktorBumdes::create(['pengurus_bumdes_id' => 1, 'modal_awal' => 2000000, 'ada_alokasi_desa' => false]); $migration = require database_path('migrations/2026_11_04_000002_add_pengelolaan_layanan_to_bumdes_kelembagaan_aktor_table.php'); $migration->down(); expect(Schema::hasColumn('bumdes_kelembagaan_aktor', 'status_pembukuan'))->toBeFalse() ->and(Schema::hasColumn('bumdes_kelembagaan_aktor', 'hambatan_usaha_lainnya'))->toBeFalse() ->and(DB::table('bumdes_kelembagaan_aktor')->where('id', $data->id)->value('modal_awal'))->toEqual(2000000); $migration->up(); expect(Schema::hasColumn('bumdes_kelembagaan_aktor', 'status_pembukuan'))->toBeTrue(); }); test('value chain tab renders separate cards quantities and the product by harvest price matrix', function () { $response = $this->get(route('bumdes.rantai-nilai.index', 1))->assertOk(); $dom = new DOMDocument; $previous = libxml_use_internal_errors(true); $dom->loadHTML($response->getContent()); libxml_clear_errors(); libxml_use_internal_errors($previous); $xpath = new DOMXPath($dom); expect($xpath->query('//form[@id="rantaiNilaiBumdesForm"]/div[contains(@class, "card")]')->length)->toBe(8) ->and($xpath->query('//form[@id="rantaiNilaiBumdesForm"]//button[@type="submit"]')->length)->toBe(8) ->and($xpath->query('//form[@id="rantaiNilaiBumdesForm"]//a[normalize-space(.)="Kembali"]')->length)->toBe(8) ->and($xpath->query('//fieldset[@data-rantai-section and @disabled]')->length)->toBe(4) ->and($xpath->query('//tr[@data-rantai-price-product]')->length)->toBe(4) ->and($xpath->query('//input[starts-with(@name, "harga_beli_petani[")]')->length)->toBe(12) ->and($xpath->query('//input[starts-with(@name, "volume_") and @type="number" and @step="0.001" and @min="0"]')->length)->toBe(6); foreach ($xpath->query('//form[@id="rantaiNilaiBumdesForm"]//legend') as $legend) { expect(trim($legend->textContent))->not->toMatch('/^\d+[.)]/'); } $response->assertSee('Saluran Pembelian/Sumber Pasokan')->assertSee('Saluran Penjualan Utama') ->assertSee('Sifat Hubungan dengan Petani Pemasok')->assertSee('Harga Beli BUMDes dari Petani'); }); test('value chain save buttons identify the next box without a standalone page heading', function () { $response = $this->get(route('bumdes.rantai-nilai.index', 1))->assertOk(); $dom = new DOMDocument; $previous = libxml_use_internal_errors(true); $dom->loadHTML($response->getContent()); libxml_clear_errors(); libxml_use_internal_errors($previous); $xpath = new DOMXPath($dom); expect($xpath->query('//div[contains(@class, "bumdes-page")]/h4')->length)->toBe(0); foreach (['pembelian' => 'penjualan', 'penjualan' => 'hubungan-pemasok', 'hubungan-pemasok' => 'harga-beli', 'harga-beli' => 'sortasi', 'sortasi' => 'pengeringan', 'pengeringan' => 'fermentasi', 'fermentasi' => 'premium-harga'] as $current => $next) { $button = $xpath->query('//div[@id="section-rantai-'.$current.'"]//button[@type="submit"]')->item(0); expect($button->getAttribute('name'))->toBe('redirect_fragment') ->and($button->getAttribute('value'))->toBe('section-rantai-'.$next); } expect($xpath->query('//div[@id="section-rantai-premium-harga"]//button[@type="submit"]')->item(0)->hasAttribute('name'))->toBeFalse(); $response->assertDontSee('Memetakan peran BUMDes dalam rantai nilai komoditas, dari pembelian hasil panen hingga akses pasar.'); }); test('value chain saves advance to the next box only when the bumdes trades', function (string $fragment, string $trading) { $url = route('bumdes.rantai-nilai.index', 1); $this->post(route('bumdes.rantai-nilai.store', 1), ['melakukan_jual_beli' => $trading, 'melakukan_pengolahan_pascapanen' => '0', 'redirect_fragment' => $fragment]) ->assertSessionHasNoErrors()->assertRedirect($url.($trading === '1' ? '#'.$fragment : '')) ->assertSessionHas('success', 'Data Rantai Nilai & Akses Pasar berhasil disimpan.'); expect(KelembagaanAktorBumdes::firstOrFail()->melakukan_jual_beli)->toBe($trading === '1'); })->with(['section-rantai-penjualan', 'section-rantai-hubungan-pemasok', 'section-rantai-harga-beli'])->with(['1', '0']); test('value chain validation errors stay at the notification instead of advancing', function () { $url = route('bumdes.rantai-nilai.index', 1); $this->from($url)->post(route('bumdes.rantai-nilai.store', 1), [ 'melakukan_jual_beli' => '1', 'redirect_fragment' => 'section-rantai-penjualan', 'sumber_pasokan' => ['lainnya'], ])->assertRedirect($url)->assertSessionHasErrors('sumber_pasokan_lainnya'); expect(KelembagaanAktorBumdes::count())->toBe(0); $this->get($url)->assertOk()->assertSee('data-has-errors="1"', false); }); test('value chain save redirects accept only known box identifiers', function () { $this->post(route('bumdes.rantai-nilai.store', 1), ['melakukan_jual_beli' => '1', 'redirect_fragment' => 'unknown-box']) ->assertSessionHasErrors('redirect_fragment'); expect(KelembagaanAktorBumdes::count())->toBe(0); }); test('value chain answers store multiple choices volumes and per-product prices without changing other tabs', function () { $data = KelembagaanAktorBumdes::create(['pengurus_bumdes_id' => 1, 'status_badan_hukum' => 'tidak_tahu', 'modal_awal' => 2000000]); $input = [ 'melakukan_jual_beli' => '1', 'melakukan_pengolahan_pascapanen' => '0', 'sumber_pasokan' => ['anggota_kelompok', 'lainnya'], 'sumber_pasokan_lainnya' => 'Kebun desa', 'bentuk_produk_dibeli' => ['buah_segar', 'biji_basah'], 'volume_pembelian' => ['panen_terakhir' => '1234.125', 'tahun_lalu' => '0', 'dua_tahun_lalu' => '25.5'], 'saluran_penjualan' => ['offtaker_eksportir', 'lainnya'], 'saluran_penjualan_lainnya' => 'Hotel setempat', 'bentuk_produk_dijual' => ['biji_kering', 'terfermentasi'], 'volume_penjualan' => ['panen_terakhir' => '250.75', 'tahun_lalu' => '0'], 'hubungan_petani_pemasok' => ['bebas', 'semi_kontrak', 'lainnya'], 'hubungan_petani_pemasok_lainnya' => 'Kemitraan kebun desa', 'mekanisme_pembayaran_petani' => 'lainnya', 'mekanisme_pembayaran_lainnya' => 'Transfer saat serah terima', 'perjanjian_petani' => 'tertulis', 'harga_beli_petani' => [ 'buah_segar' => ['panen_terakhir' => '12.500', 'tahun_lalu' => '10.000', 'dua_tahun_lalu' => '0'], 'biji_basah' => ['panen_terakhir' => '0'], 'biji_kering' => ['panen_terakhir' => ['invalid']], ], 'penetapan_harga_beli' => 'lainnya', 'penetapan_harga_beli_lainnya' => 'Musyawarah dengan pemasok', 'modal_awal' => '999', ]; $url = route('bumdes.rantai-nilai.index', 1); $this->post(route('bumdes.rantai-nilai.store', 1), $input)->assertRedirect($url)->assertSessionHasNoErrors() ->assertSessionHas('success', 'Data Rantai Nilai & Akses Pasar berhasil disimpan.'); $data->refresh(); expect($data->melakukan_jual_beli)->toBeTrue() ->and($data->sumber_pasokan->map->value->all())->toBe(['anggota_kelompok', 'lainnya']) ->and($data->bentuk_produk_dibeli->map->value->all())->toBe(['buah_segar', 'biji_basah']) ->and($data->volume_pembelian)->toBe(['panen_terakhir' => '1234.125', 'tahun_lalu' => '0', 'dua_tahun_lalu' => '25.5']) ->and($data->saluran_penjualan->map->value->all())->toBe(['offtaker_eksportir', 'lainnya']) ->and($data->volume_penjualan)->toBe(['panen_terakhir' => '250.75', 'tahun_lalu' => '0']) ->and($data->harga_beli_petani)->toBe(['buah_segar' => ['panen_terakhir' => '12500', 'tahun_lalu' => '10000', 'dua_tahun_lalu' => '0'], 'biji_basah' => ['panen_terakhir' => '0']]) ->and($data->hubungan_petani_pemasok->map->value->all())->toBe(['bebas', 'semi_kontrak', 'lainnya']) ->and($data->mekanisme_pembayaran_lainnya)->toBe('Transfer saat serah terima') ->and($data->penetapan_harga_beli_lainnya)->toBe('Musyawarah dengan pemasok') ->and($data->modal_awal)->toBe('2000000.00'); $this->get($url)->assertOk()->assertSee('value="12.500"', false)->assertSee('Kebun desa')->assertSee('Hotel setempat'); $this->put(route('bumdes.update', 1), $this->profileInput)->assertSessionHasNoErrors(); $this->post(route('bumdes.operasional.store', 1), ['modal_awal' => '3000000'])->assertSessionHasNoErrors(); $this->post(route('bumdes.kelembagaan-aktor.store', 1), ['status_badan_hukum' => 'tidak_tahu'])->assertSessionHasNoErrors(); expect($data->refresh()->sumber_pasokan_lainnya)->toBe('Kebun desa') ->and($data->harga_beli_petani['buah_segar']['panen_terakhir'])->toBe('12500'); }); test('not trading skips and clears the whole value chain questionnaire while preserving operational answers', function () { $data = KelembagaanAktorBumdes::create([ 'pengurus_bumdes_id' => 1, 'modal_awal' => 2000000, 'melakukan_jual_beli' => true, 'sumber_pasokan' => ['lainnya'], 'sumber_pasokan_lainnya' => 'Kebun desa', 'bentuk_produk_dibeli' => ['buah_segar'], 'harga_beli_petani' => ['buah_segar' => ['panen_terakhir' => '12500']], ]); $this->post(route('bumdes.rantai-nilai.store', 1), [ 'melakukan_jual_beli' => '0', 'melakukan_pengolahan_pascapanen' => '0', 'sumber_pasokan' => 'invalid', 'sumber_pasokan_lainnya' => ['invalid'], 'volume_pembelian' => ['invalid'], 'bentuk_produk_dibeli' => 'invalid', 'harga_beli_petani' => 'invalid', 'penetapan_harga_beli' => 'invalid', ])->assertSessionHasNoErrors(); expect($data->refresh()->melakukan_jual_beli)->toBeFalse() ->and($data->sumber_pasokan->all())->toBe([]) ->and($data->sumber_pasokan_lainnya)->toBeNull() ->and($data->harga_beli_petani)->toBe([]) ->and($data->modal_awal)->toBe('2000000.00'); $this->get(route('bumdes.rantai-nilai.index', 1))->assertOk(); }); test('not yet selling clears product and sales volumes and ignores their invalid details', function () { $this->post(route('bumdes.rantai-nilai.store', 1), [ 'melakukan_jual_beli' => '1', 'melakukan_pengolahan_pascapanen' => '0', 'saluran_penjualan' => ['belum_menjual'], 'bentuk_produk_dijual' => 'invalid', 'volume_penjualan' => ['panen_terakhir' => ['invalid']], ])->assertSessionHasNoErrors(); $data = KelembagaanAktorBumdes::firstOrFail(); expect($data->bentuk_produk_dijual->all())->toBe([])->and($data->volume_penjualan)->toBe([]); }); test('value chain validates applicability other choices quantities and price matrix data', function (array $input, array $errors) { $url = route('bumdes.rantai-nilai.index', 1); $this->from($url)->post(route('bumdes.rantai-nilai.store', 1), $input)->assertRedirect($url)->assertSessionHasErrors($errors); expect(KelembagaanAktorBumdes::count())->toBe(0); $this->get($url)->assertOk()->assertSee('data-has-errors="1"', false); })->with([ [[], ['melakukan_jual_beli']], [['melakukan_jual_beli' => 'invalid'], ['melakukan_jual_beli']], [['melakukan_jual_beli' => ['invalid']], ['melakukan_jual_beli']], [['melakukan_jual_beli' => '1', 'sumber_pasokan' => ['lainnya']], ['sumber_pasokan_lainnya']], [['melakukan_jual_beli' => '1', 'saluran_penjualan' => ['lainnya']], ['saluran_penjualan_lainnya']], [['melakukan_jual_beli' => '1', 'hubungan_petani_pemasok' => ['lainnya']], ['hubungan_petani_pemasok_lainnya']], [['melakukan_jual_beli' => '1', 'mekanisme_pembayaran_petani' => 'lainnya'], ['mekanisme_pembayaran_lainnya']], [['melakukan_jual_beli' => '1', 'penetapan_harga_beli' => 'lainnya'], ['penetapan_harga_beli_lainnya']], [['melakukan_jual_beli' => '1', 'sumber_pasokan' => ['lainnya'], 'sumber_pasokan_lainnya' => ['invalid']], ['sumber_pasokan_lainnya']], [['melakukan_jual_beli' => '1', 'saluran_penjualan' => ['belum_menjual', 'pasar_lokal']], ['saluran_penjualan']], [['melakukan_jual_beli' => '1', 'sumber_pasokan' => ['anggota_kelompok', 'anggota_kelompok']], ['sumber_pasokan.0']], [['melakukan_jual_beli' => '1', 'hubungan_petani_pemasok' => ['invalid']], ['hubungan_petani_pemasok.0']], [['melakukan_jual_beli' => '1', 'perjanjian_petani' => 'invalid'], ['perjanjian_petani']], [['melakukan_jual_beli' => '1', 'bentuk_produk_dibeli' => ['invalid']], ['bentuk_produk_dibeli.0']], [['melakukan_jual_beli' => '1', 'volume_pembelian' => ['panen_terakhir' => '-1']], ['volume_pembelian.panen_terakhir']], [['melakukan_jual_beli' => '1', 'volume_pembelian' => ['panen_terakhir' => '1.2345']], ['volume_pembelian.panen_terakhir']], [['melakukan_jual_beli' => '1', 'volume_penjualan' => ['tahun_lalu' => 'abc']], ['volume_penjualan.tahun_lalu']], [['melakukan_jual_beli' => '1', 'volume_pembelian' => ['invalid' => 1]], ['volume_pembelian']], [['melakukan_jual_beli' => '1', 'bentuk_produk_dibeli' => ['buah_segar'], 'harga_beli_petani' => ['buah_segar' => ['panen_terakhir' => '-1']]], ['harga_beli_petani.buah_segar.panen_terakhir']], [['melakukan_jual_beli' => '1', 'bentuk_produk_dibeli' => ['buah_segar'], 'harga_beli_petani' => ['buah_segar' => ['panen_terakhir' => '1.5']]], ['harga_beli_petani.buah_segar.panen_terakhir']], [['melakukan_jual_beli' => '1', 'bentuk_produk_dibeli' => ['buah_segar'], 'harga_beli_petani' => ['buah_segar' => ['panen_terakhir' => ['invalid']]]], ['harga_beli_petani.buah_segar.panen_terakhir']], [['melakukan_jual_beli' => '1', 'harga_beli_petani' => ['invalid' => []]], ['harga_beli_petani']], [['melakukan_jual_beli' => '1', 'bentuk_produk_dibeli' => ['buah_segar'], 'harga_beli_petani' => ['buah_segar' => ['invalid' => '1000']]], ['harga_beli_petani.buah_segar']], ]); test('value chain skips inactive other descriptions and unselected product prices', function () { $this->post(route('bumdes.rantai-nilai.store', 1), [ 'melakukan_jual_beli' => '1', 'melakukan_pengolahan_pascapanen' => '0', 'bentuk_produk_dibeli' => ['buah_segar'], 'harga_beli_petani' => ['buah_segar' => ['panen_terakhir' => '0'], 'biji_basah' => 'invalid'], 'sumber_pasokan' => ['anggota_kelompok'], 'sumber_pasokan_lainnya' => ['invalid'], 'mekanisme_pembayaran_petani' => 'tunai', 'mekanisme_pembayaran_lainnya' => ['invalid'], 'penetapan_harga_beli' => 'pasar_setempat', 'penetapan_harga_beli_lainnya' => ['invalid'], ])->assertSessionHasNoErrors(); $data = KelembagaanAktorBumdes::firstOrFail(); expect($data->harga_beli_petani)->toBe(['buah_segar' => ['panen_terakhir' => '0']]) ->and($data->sumber_pasokan_lainnya)->toBeNull()->and($data->mekanisme_pembayaran_lainnya)->toBeNull(); }); test('value chain is locked for incomplete profiles and workshop roles', function () { DB::table('bumdes_pengurus')->where('id', 1)->update(['alamat' => null]); $this->get(route('bumdes.rantai-nilai.index', 1))->assertForbidden(); $this->post(route('bumdes.rantai-nilai.store', 1), ['melakukan_jual_beli' => '0'])->assertForbidden(); DB::table('bumdes_pengurus')->where('id', 1)->update(['alamat' => 'Desa A']); auth()->user()->role = 'fasilitator'; $this->get(route('bumdes.rantai-nilai.index', 1))->assertForbidden(); }); test('value chain migration rolls back without changing earlier operational data', function () { $data = KelembagaanAktorBumdes::create(['pengurus_bumdes_id' => 1, 'modal_awal' => 2000000, 'status_pembukuan' => 'rutin_tertib']); $migration = require database_path('migrations/2026_11_04_000003_add_rantai_nilai_to_bumdes_kelembagaan_aktor_table.php'); $migration->down(); expect(Schema::hasColumn('bumdes_kelembagaan_aktor', 'sumber_pasokan'))->toBeFalse() ->and(DB::table('bumdes_kelembagaan_aktor')->where('id', $data->id)->value('status_pembukuan'))->toBe('rutin_tertib'); $migration->up(); expect(Schema::hasColumn('bumdes_kelembagaan_aktor', 'sumber_pasokan'))->toBeTrue(); }); test('postharvest opportunity cards show all choices and start with inactive follow-up fields', function () { $response = $this->get(route('bumdes.rantai-nilai.index', 1))->assertOk(); $dom = new DOMDocument; $previous = libxml_use_internal_errors(true); $dom->loadHTML($response->getContent()); libxml_clear_errors(); libxml_use_internal_errors($previous); $xpath = new DOMXPath($dom); expect($xpath->query('//div[@data-postharvest-card]')->length)->toBe(4) ->and($xpath->query('//fieldset[@data-postharvest-section and @disabled]')->length)->toBe(4) ->and($xpath->query('//input[@name="status_sortasi"]')->length)->toBe(3) ->and($xpath->query('//input[@name="status_pengeringan"]')->length)->toBe(3) ->and($xpath->query('//input[@name="status_fermentasi"]')->length)->toBe(4) ->and($xpath->query('//input[@name="model_fermentasi[]"]')->length)->toBe(4); foreach (['kriteria_sortasi', 'cara_tempat_pengeringan', 'proses_fermentasi'] as $field) { $input = $xpath->query('//textarea[@name="'.$field.'"]')->item(0); expect($input->hasAttribute('disabled'))->toBeTrue()->and($input->hasAttribute('required'))->toBeFalse(); } $response->assertSee('Peluang Nilai Tambah')->assertSee('Sortasi & Pemilihan Kualitas', false) ->assertSee('Praktik Pengeringan')->assertSee('Praktik Fermentasi')->assertSee('Besaran Premium Harga'); }); test('postharvest activities save independently of trading and retain existing operational answers', function (string $trading) { $data = KelembagaanAktorBumdes::create(['pengurus_bumdes_id' => 1, 'modal_awal' => 2000000, 'status_badan_hukum' => 'tidak_tahu']); $input = [ 'melakukan_jual_beli' => $trading, 'melakukan_pengolahan_pascapanen' => '1', 'status_sortasi' => 'rutin_standar', 'kriteria_sortasi' => 'Ukuran seragam dan bebas cacat', 'status_pengeringan' => 'kadang', 'cara_tempat_pengeringan' => 'Dijemur di atas para-para beratap', 'status_fermentasi' => 'pernah_mencoba', 'model_fermentasi' => ['individual', 'unit_bumdes', 'campuran'], 'proses_fermentasi' => 'Fermentasi terkontrol dalam kotak kayu', 'premium_harga_pascapanen' => '2.500', 'redirect_fragment' => 'section-rantai-pengeringan', ]; $url = route('bumdes.rantai-nilai.index', 1); $this->post(route('bumdes.rantai-nilai.store', 1), $input)->assertSessionHasNoErrors()->assertRedirect($url.'#section-rantai-pengeringan'); $data->refresh(); expect($data->melakukan_pengolahan_pascapanen)->toBeTrue() ->and($data->status_sortasi)->toBe(StatusSortasiBumdes::RUTIN_STANDAR) ->and($data->kriteria_sortasi)->toBe('Ukuran seragam dan bebas cacat') ->and($data->status_pengeringan->value)->toBe('kadang') ->and($data->status_fermentasi->value)->toBe('pernah_mencoba') ->and($data->model_fermentasi->map->value->all())->toBe(['individual', 'unit_bumdes', 'campuran']) ->and($data->proses_fermentasi)->toBe('Fermentasi terkontrol dalam kotak kayu') ->and($data->premium_harga_pascapanen)->toBe('2500.00') ->and($data->modal_awal)->toBe('2000000.00') ->and($data->status_badan_hukum->value)->toBe('tidak_tahu'); $this->get($url)->assertOk()->assertSee('value="2.500"', false)->assertSee('Ukuran seragam dan bebas cacat'); $this->put(route('bumdes.update', 1), $this->profileInput)->assertSessionHasNoErrors(); $this->post(route('bumdes.operasional.store', 1), ['modal_awal' => '3000000'])->assertSessionHasNoErrors(); expect($data->refresh()->premium_harga_pascapanen)->toBe('2500.00')->and($data->kriteria_sortasi)->toBe('Ukuran seragam dan bebas cacat'); })->with(['1', '0']); test('inactive postharvest follow-ups ignore malformed values and clear old details', function () { $data = KelembagaanAktorBumdes::create([ 'pengurus_bumdes_id' => 1, 'melakukan_jual_beli' => true, 'melakukan_pengolahan_pascapanen' => true, 'status_sortasi' => 'rutin_standar', 'kriteria_sortasi' => 'Ukuran', 'status_pengeringan' => 'rutin', 'cara_tempat_pengeringan' => 'Jemur', 'status_fermentasi' => 'rutin', 'model_fermentasi' => ['unit_bumdes'], 'proses_fermentasi' => 'Kotak kayu', ]); $this->post(route('bumdes.rantai-nilai.store', 1), [ 'melakukan_jual_beli' => '1', 'melakukan_pengolahan_pascapanen' => '1', 'status_sortasi' => 'belum_pernah', 'kriteria_sortasi' => ['invalid'], 'status_pengeringan' => 'tidak_pernah', 'cara_tempat_pengeringan' => ['invalid'], 'status_fermentasi' => 'direncanakan', 'model_fermentasi' => 'invalid', 'proses_fermentasi' => ['invalid'], 'premium_harga_pascapanen' => '0', ])->assertSessionHasNoErrors(); expect($data->refresh()->kriteria_sortasi)->toBeNull()->and($data->cara_tempat_pengeringan)->toBeNull() ->and($data->model_fermentasi->all())->toBe([])->and($data->proses_fermentasi)->toBeNull() ->and($data->premium_harga_pascapanen)->toBe('0.00'); }); test('no postharvest processing clears its questionnaire without clearing buying and selling', function () { $data = KelembagaanAktorBumdes::create([ 'pengurus_bumdes_id' => 1, 'melakukan_pengolahan_pascapanen' => true, 'status_sortasi' => 'rutin_standar', 'kriteria_sortasi' => 'Ukuran', 'model_fermentasi' => ['unit_bumdes'], 'premium_harga_pascapanen' => 2500, ]); $this->post(route('bumdes.rantai-nilai.store', 1), [ 'melakukan_jual_beli' => '1', 'sumber_pasokan' => ['anggota_kelompok'], 'melakukan_pengolahan_pascapanen' => '0', 'status_sortasi' => 'invalid', 'kriteria_sortasi' => ['invalid'], 'model_fermentasi' => 'invalid', 'premium_harga_pascapanen' => ['invalid'], 'redirect_fragment' => 'section-rantai-pengeringan', ])->assertSessionHasNoErrors()->assertRedirect(route('bumdes.rantai-nilai.index', 1)); expect($data->refresh()->melakukan_pengolahan_pascapanen)->toBeFalse() ->and($data->status_sortasi)->toBeNull()->and($data->kriteria_sortasi)->toBeNull() ->and($data->model_fermentasi->all())->toBe([])->and($data->premium_harga_pascapanen)->toBeNull() ->and($data->sumber_pasokan->map->value->all())->toBe(['anggota_kelompok']); }); test('postharvest details validate active answers before any data is saved', function (array $input, array $errors) { $url = route('bumdes.rantai-nilai.index', 1); $this->from($url)->post(route('bumdes.rantai-nilai.store', 1), ['melakukan_jual_beli' => '0', 'melakukan_pengolahan_pascapanen' => '1', ...$input]) ->assertRedirect($url)->assertSessionHasErrors($errors); expect(KelembagaanAktorBumdes::count())->toBe(0); $this->get($url)->assertOk()->assertSee('data-has-errors="1"', false); })->with([ [['melakukan_pengolahan_pascapanen' => ['invalid']], ['melakukan_pengolahan_pascapanen']], [['status_sortasi' => 'rutin_standar'], ['kriteria_sortasi']], [['status_sortasi' => 'tidak_konsisten'], ['kriteria_sortasi']], [['status_sortasi' => 'invalid'], ['status_sortasi']], [['status_pengeringan' => 'rutin'], ['cara_tempat_pengeringan']], [['status_pengeringan' => 'kadang'], ['cara_tempat_pengeringan']], [['status_pengeringan' => 'invalid'], ['status_pengeringan']], [['status_fermentasi' => 'rutin'], ['model_fermentasi', 'proses_fermentasi']], [['status_fermentasi' => 'pernah_mencoba'], ['model_fermentasi', 'proses_fermentasi']], [['status_fermentasi' => 'invalid'], ['status_fermentasi']], [['status_fermentasi' => 'rutin', 'model_fermentasi' => ['invalid'], 'proses_fermentasi' => 'Kotak kayu'], ['model_fermentasi.0']], [['status_fermentasi' => 'rutin', 'model_fermentasi' => ['unit_bumdes', 'unit_bumdes'], 'proses_fermentasi' => 'Kotak kayu'], ['model_fermentasi.0']], [['status_sortasi' => 'rutin_standar', 'kriteria_sortasi' => ['invalid']], ['kriteria_sortasi']], [['premium_harga_pascapanen' => '-1'], ['premium_harga_pascapanen']], [['premium_harga_pascapanen' => '1.5'], ['premium_harga_pascapanen']], [['premium_harga_pascapanen' => '10000000000000000'], ['premium_harga_pascapanen']], [['premium_harga_pascapanen' => ['invalid']], ['premium_harga_pascapanen']], ]); test('postharvest save targets follow processing eligibility', function (string $fragment, string $processing) { $url = route('bumdes.rantai-nilai.index', 1); $this->post(route('bumdes.rantai-nilai.store', 1), [ 'melakukan_jual_beli' => '0', 'melakukan_pengolahan_pascapanen' => $processing, 'redirect_fragment' => $fragment, ])->assertSessionHasNoErrors()->assertRedirect($url.($processing === '1' ? '#'.$fragment : '')); })->with(['section-rantai-pengeringan', 'section-rantai-fermentasi', 'section-rantai-premium-harga'])->with(['1', '0']); test('postharvest migration rolls back only the newly added opportunity fields', function () { $data = KelembagaanAktorBumdes::create(['pengurus_bumdes_id' => 1, 'melakukan_jual_beli' => true, 'sumber_pasokan' => ['campuran']]); $migration = require database_path('migrations/2026_11_05_000001_add_peluang_nilai_tambah_to_bumdes_kelembagaan_aktor_table.php'); $migration->down(); expect(Schema::hasColumn('bumdes_kelembagaan_aktor', 'status_sortasi'))->toBeFalse() ->and(DB::table('bumdes_kelembagaan_aktor')->where('id', $data->id)->value('sumber_pasokan'))->toBe('["campuran"]'); $migration->up(); expect(Schema::hasColumn('bumdes_kelembagaan_aktor', 'status_sortasi'))->toBeTrue(); }); test('postharvest applicability requires an explicit answer and displays its error above the form', function () { $url = route('bumdes.rantai-nilai.index', 1); $this->from($url)->post(route('bumdes.rantai-nilai.store', 1), ['melakukan_jual_beli' => '1']) ->assertRedirect($url)->assertSessionHasErrors(['melakukan_pengolahan_pascapanen' => 'Kegiatan pengolahan pascapanen wajib dipilih.']); expect(KelembagaanAktorBumdes::count())->toBe(0); $response = $this->get($url)->assertOk()->assertSee('Kegiatan pengolahan pascapanen wajib dipilih.') ->assertSee('data-has-errors="1"', false); $dom = new DOMDocument; $previous = libxml_use_internal_errors(true); $dom->loadHTML($response->getContent()); libxml_clear_errors(); libxml_use_internal_errors($previous); $xpath = new DOMXPath($dom); expect($xpath->query('//input[@name="melakukan_pengolahan_pascapanen" and @required]')->length)->toBe(2) ->and($xpath->query('//legend[contains(., "Kegiatan Pengolahan Pascapanen")]/span[contains(@class, "text-danger")]')->length)->toBe(1); }); test('postharvest applicability accepts either yes or no as a required answer', function (string $answer) { $this->post(route('bumdes.rantai-nilai.store', 1), ['melakukan_jual_beli' => '0', 'melakukan_pengolahan_pascapanen' => $answer]) ->assertSessionHasNoErrors(); expect(KelembagaanAktorBumdes::firstOrFail()->melakukan_pengolahan_pascapanen)->toBe($answer === '1'); })->with(['0', '1']); test('reported bumdes value chain URL resolves the parent model for both display and save', function () { $url = '/bumdes/2/rantai-nilai'; $this->get($url)->assertOk()->assertSee('rantaiNilaiBumdesForm', false); $this->post($url, ['melakukan_jual_beli' => '0', 'melakukan_pengolahan_pascapanen' => '0']) ->assertRedirect(route('bumdes.rantai-nilai.index', 2))->assertSessionHasNoErrors(); $data = KelembagaanAktorBumdes::where('pengurus_bumdes_id', 2)->firstOrFail(); expect($data->melakukan_jual_beli)->toBeFalse()->and($data->melakukan_pengolahan_pascapanen)->toBeFalse(); }); test('shared bumdes profile card presents institutional data across all profile tabs', function () { KelembagaanAktorBumdes::create([ 'pengurus_bumdes_id' => 1, 'status_keaktifan' => 'aktif_penuh', 'status_badan_hukum' => 'proses_pendaftaran', 'unit_usaha' => ['simpan_pinjam', 'perdagangan_toko_desa', 'jasa_lainnya'], 'unit_usaha_jasa_lainnya' => 'Pengiriman hasil panen', 'status_unit_komoditas' => 'ya', 'nama_unit_komoditas' => 'Unit Pengolahan Kopi', 'peran_rantai_nilai' => 'mengolah_sendiri', 'cakupan_layanan' => 'desa_sendiri_dan_lain', 'jumlah_petani_desa_sendiri' => 120, 'jumlah_petani_desa_lain' => 35, 'desa_lain_dilayani' => 'Desa B, Desa C', ]); DB::table('bumdes_pengurus')->where('id', 1)->update(['nomor_telepon' => '081234567890']); foreach (['bumdes.edit', 'bumdes.kelembagaan-aktor.index', 'bumdes.operasional.index', 'bumdes.rantai-nilai.index'] as $route) { $response = $this->get(route($route, 1))->assertOk(); $dom = new DOMDocument; $previous = libxml_use_internal_errors(true); $dom->loadHTML($response->getContent()); libxml_clear_errors(); libxml_use_internal_errors($previous); $xpath = new DOMXPath($dom); $card = $xpath->query('//section[@id="bumdes-profile-summary"]')->item(0); expect($card)->not->toBeNull(); expect($card->textContent)->toContain('Desa A', 'Aktif beroperasi penuh', 'Proses pendaftaran', 'Alpha', '081234567890', 'Pengiriman hasil panen') ->not->toContain('Jenis Kelamin', 'Alamat Domisili', 'Petani Terlayani', 'Jangkauan Layanan', 'Peran Rantai Nilai', 'Unit Komoditas', 'Unit Pengolahan Kopi', 'Desa B, Desa C'); expect($xpath->query('.//*[@data-bumdes-farmer-total]', $card)->length)->toBe(0); expect($xpath->query('.//button[@data-bs-target="#bumdes-summary-extra-units"]', $card)->length)->toBe(1); expect($xpath->query('//a[contains(@class, "nav-link") and @href="'.route('bumdes.rantai-nilai.index', 1).'"]')->length)->toBe(1); } }); test('bumdes summary uses the latest profile or tab update and handles a new unsaved profile', function () { KelembagaanAktorBumdes::create(['pengurus_bumdes_id' => 1]); DB::table('bumdes_pengurus')->where('id', 1)->update(['updated_at' => '2026-01-01 08:00:00']); DB::table('bumdes_kelembagaan_aktor')->where('pengurus_bumdes_id', 1)->update(['updated_at' => '2026-02-02 09:00:00']); $summary = ($this->bumdesAction)('edit', null, '1')->getData()['ringkasanProfil']; expect($summary['diperbarui']->toDateTimeString())->toBe('2026-02-02 09:00:00') ->and($summary['keaktifan']['label'])->toBe('Belum terisi') ->and($summary['unit_usaha'])->toBe([]); DB::table('bumdes_pengurus')->where('id', 1)->update(['updated_at' => '2026-03-03 10:00:00']); expect(($this->bumdesAction)('edit', null, '1')->getData()['ringkasanProfil']['diperbarui']->toDateTimeString())->toBe('2026-03-03 10:00:00'); $view = ($this->bumdesAction)('create'); $summary = $view->getData()['ringkasanProfil']; expect($summary['judul'])->toBe('Profil BUMDes Baru')->and($summary['unit_usaha'])->toBe([]) ->and($summary['responden'])->toBe('Belum terisi')->and($summary['diperbarui'])->toBeNull(); expect($view->render())->toContain('Belum tersimpan'); });