null, 'master_kecamatan' => 'kabupaten_kota_id', 'master_desa_kelurahan' => 'kecamatan_id', 'master_komoditas' => null, ] as $name => $parent) { Schema::create($name, function (Blueprint $table) use ($parent) { $table->id(); $table->string('nama'); if ($parent) $table->integer($parent); $table->softDeletes(); }); } Schema::create('survey_kelompok_tani', function (Blueprint $table) { $table->id(); $table->string('nama_kelompok'); $table->string('nama_responden')->nullable(); $table->integer('desa_kelurahan_id'); $table->string('jenis_kelompok')->nullable(); $table->string('status_legalitas')->nullable(); $table->boolean('pernah_mendapatkan_bantuan')->nullable(); $table->integer('created_by')->nullable(); $table->json('komoditas_id')->nullable(); foreach (['jumlah_pengurus', 'jumlah_pengurus_aktif', 'jumlah_anggota_terdaftar', 'jumlah_anggota_aktif'] as $field) { $table->integer($field)->nullable(); } $table->timestamps(); $table->softDeletes(); }); Schema::create('users', function (Blueprint $table) { $table->id(); $table->string('name'); $table->softDeletes(); }); DB::table('users')->insert(['id' => 7, 'name' => 'Enumerator Uji']); $user = new User(['name' => 'Admin Uji', 'email' => 'admin@example.test', 'role' => 'admin']); $user->id = 7; $this->be($user); Event::forget('composing: *'); view()->share('errors', new ViewErrorBag()); DB::table('master_kabupaten_kota')->insert([ ['id' => 1, 'nama' => 'Kabupaten Ngada'], ['id' => 2, 'nama' => 'Kabupaten Lain'], ]); DB::table('master_kecamatan')->insert([ ['id' => 1, 'nama' => 'Kecamatan A', 'kabupaten_kota_id' => 1], ['id' => 2, 'nama' => 'Kecamatan B', 'kabupaten_kota_id' => 1], ['id' => 3, 'nama' => 'Kecamatan C', 'kabupaten_kota_id' => 2], ]); DB::table('master_desa_kelurahan')->insert([ ['id' => 1, 'nama' => 'Desa A', 'kecamatan_id' => 1], ['id' => 2, 'nama' => 'Desa B', 'kecamatan_id' => 1], ['id' => 3, 'nama' => 'Desa C', 'kecamatan_id' => 2], ['id' => 4, 'nama' => 'Desa D', 'kecamatan_id' => 3], ]); DB::table('master_komoditas')->insert([ ['id' => 1, 'nama' => 'Kakao'], ['id' => 2, 'nama' => 'Kopi'], ['id' => 11, 'nama' => 'Vanili'], ]); foreach ([ [1, 'Alpha', 'Budi', 1, 'Kelompok Tani (Poktan)', ['1', '2'], 3, 2, 30, 25, null], [2, 'Beta', 'Siti', 2, 'Kelompok Wanita Tani (KWT)', [1], 5, 4, 40, 35, null], [3, 'Citra', 'Ani', 3, 'Kelompok Tani (Poktan)', [2, 11], null, null, 0, 0, null], [4, 'Diluar', 'Toni', 4, 'Kelompok Tani (Poktan)', [1], 99, 99, 900, 900, null], [5, 'Dihapus', 'Dewi', 1, 'Kelompok Tani (Poktan)', [1], 99, 99, 900, 900, '2026-01-01'], ] as [$id, $nama, $responden, $desa, $jenis, $komoditas, $pengurus, $pengurusAktif, $anggota, $anggotaAktif, $deletedAt]) { DB::table('survey_kelompok_tani')->insert([ 'id' => $id, 'nama_kelompok' => $nama, 'nama_responden' => $responden, 'desa_kelurahan_id' => $desa, 'jenis_kelompok' => $jenis, 'komoditas_id' => json_encode($komoditas), 'jumlah_pengurus' => $pengurus, 'jumlah_pengurus_aktif' => $pengurusAktif, 'jumlah_anggota_terdaftar' => $anggota, 'jumlah_anggota_aktif' => $anggotaAktif, 'created_at' => "2026-01-0{$id}", 'deleted_at' => $deletedAt, 'status_legalitas' => match ($id) { 1 => 'Sudah ada SK dari desa/kecamatan', 2 => 'Sedang proses pengajuan', 4, 5 => 'Sudah ada SK dan terdaftar di Dinas/Kabupaten', default => null, }, 'pernah_mendapatkan_bantuan' => match ($id) { 1 => true, 2 => false, default => null }, 'created_by' => 7, ]); } $this->listKelompok = function (array $filters = []) { $request = IndexKelompokTaniRequest::create('/survey/kelompok-tani', 'GET', $filters); $request->setContainer($this->app)->setRedirector(app('redirect')); $this->app->instance('request', $request); $request->validateResolved(); return app(SurveyKelompokTaniController::class)->index($request)->getData(); }; }); test('kelompok cards summarize the default region and exclude deleted groups', function () { $data = ($this->listKelompok)(); expect($data['kelompokTani']->pluck('id')->all())->toBe([3, 2, 1]); expect((int) $data['summary']->total_kelompok)->toBe(3) ->and((int) $data['summary']->total_jenis)->toBe(2) ->and((int) $data['summary']->total_pengurus)->toBe(8) ->and((int) $data['summary']->pengurus_aktif)->toBe(6) ->and((int) $data['summary']->total_anggota)->toBe(70) ->and((int) $data['summary']->anggota_aktif)->toBe(60); expect((int) $data['summary']->total_desa)->toBe(3); expect((int) $data['summary']->memiliki_sk)->toBe(1); expect($data['kecamatanOptions']->pluck('id')->all())->toBe([1, 2]); }); test('kelompok region filters constrain rows cards and dependent options', function () { $data = ($this->listKelompok)(['kecamatan_id' => 1, 'desa_kelurahan_id' => 2]); expect($data['kelompokTani']->pluck('id')->all())->toBe([2]); expect((int) $data['summary']->total_anggota)->toBe(40); expect($data['desaOptions']->pluck('id')->all())->toBe([1, 2]); expect(($this->listKelompok)(['kecamatan_id' => 2])['kelompokTani']->pluck('id')->all())->toBe([3]); expect(($this->listKelompok)(['kabupaten_kota_id' => 2])['kelompokTani']->pluck('id')->all())->toBe([4]); }); test('kelompok commodity filter matches string and numeric JSON ids exactly', function () { $data = ($this->listKelompok)(['komoditas_id' => '1']); expect($data['kelompokTani']->pluck('id')->all())->toBe([2, 1]); expect((int) $data['summary']->total_kelompok)->toBe(2); expect(($this->listKelompok)(['komoditas_id' => 2, 'jenis_kelompok' => 'Kelompok Tani (Poktan)'])['kelompokTani']->pluck('id')->all())->toBe([3, 1]); expect(($this->listKelompok)(['jenis_kelompok' => 'Kelompok Wanita Tani (KWT)'])['kelompokTani']->pluck('id')->all())->toBe([2]); }); test('kelompok search matches group and respondent names without escaping region filters', function () { expect(($this->listKelompok)(['search' => 'aLpHa'])['kelompokTani']->pluck('id')->all())->toBe([1]); expect(($this->listKelompok)(['search' => 'sITI'])['kelompokTani']->pluck('id')->all())->toBe([2]); $data = ($this->listKelompok)(['search' => 'Toni']); expect($data['kelompokTani']->total())->toBe(0); expect((int) $data['summary']->total_pengurus)->toBe(0) ->and((int) $data['summary']->total_anggota)->toBe(0); }); test('kelompok sorting supports names membership totals and safe fallback', function () { foreach ([ 'nama_asc' => [1, 2, 3], 'nama_desc' => [3, 2, 1], 'anggota_desc' => [2, 1, 3], 'anggota_asc' => [3, 1, 2], 'unknown' => [3, 2, 1], ] as $sort => $ids) { expect(($this->listKelompok)(['sort_by' => $sort])['kelompokTani']->pluck('id')->all())->toBe($ids); } }); test('kelompok totals cover every result and pagination preserves all filters', function () { for ($index = 0; $index < 12; $index++) { DB::table('survey_kelompok_tani')->insert([ 'nama_kelompok' => 'Kelompok tambahan ' . $index, 'desa_kelurahan_id' => 1, 'jenis_kelompok' => 'Kelompok Tani (Poktan)', 'komoditas_id' => '["1"]', 'jumlah_anggota_terdaftar' => 10, ]); } $data = ($this->listKelompok)(['komoditas_id' => 1, 'sort_by' => 'nama_asc', 'page' => 2, 'per_page' => '10']); expect($data['kelompokTani']->count())->toBe(4); expect($data['kelompokTani']->perPage())->toBe(10); expect((int) $data['summary']->total_kelompok)->toBe(14) ->and((int) $data['summary']->total_anggota)->toBe(190); parse_str(parse_url($data['kelompokTani']->url(1), PHP_URL_QUERY), $params); expect($params)->toMatchArray(['komoditas_id' => '1', 'sort_by' => 'nama_asc', 'per_page' => '10']); }); test('kelompok filters reject malformed ids and unsupported page sizes', function (array $filters) { expect(fn () => ($this->listKelompok)($filters))->toThrow(ValidationException::class); })->with([ [['kecamatan_id' => 'invalid']], [['komoditas_id' => 999]], [['per_page' => 0]], [['search' => ['invalid']]], [['status_legalitas' => ['invalid']]], ]); test('legal status filters keep cards and rows consistent', function () { $sk = ($this->listKelompok)(['status_legalitas' => 'Sudah ada SK dari desa/kecamatan']); expect($sk['kelompokTani']->pluck('id')->all())->toBe([1]); expect((int) $sk['summary']->memiliki_sk)->toBe(1); expect((int) $sk['summary']->total_pengurus)->toBe(3); $proses = ($this->listKelompok)(['status_legalitas' => 'Sedang proses pengajuan']); expect($proses['kelompokTani']->pluck('id')->all())->toBe([2]); expect((int) $proses['summary']->total_anggota)->toBe(40); expect((int) $proses['summary']->memiliki_sk)->toBe(0); }); test('group summaries resolve commodities locations and legal status from recorded data', function () { $data = ($this->listKelompok)(); expect($data['ringkasanKelompok'][1]['komoditas'])->toBe(['Kakao', 'Kopi']); expect($data['ringkasanKelompok'][3]['komoditas'])->toBe(['Kopi', 'Vanili']); expect($data['ringkasanKelompok'][1]['lokasi'])->toBe('Desa A ยท Kecamatan A'); expect($data['ringkasanKelompok'][1]['legalitas'])->toBe(['label' => 'Sudah ada SK', 'warna' => 'success']); expect($data['ringkasanKelompok'][3]['legalitas']['label'])->toBe('Belum terisi'); expect($data['kelompokTani']->first()->creator->name)->toBe('Enumerator Uji'); expect(($this->listKelompok)(['search' => 'Desa B'])['kelompokTani']->pluck('id')->all())->toBe([2]); expect(($this->listKelompok)(['search' => 'Kecamatan B'])['kelompokTani']->pluck('id')->all())->toBe([3]); }); test('new filters survive pagination and filtered page totals cover every matching group', function () { $filters = ['status_legalitas' => 'Sedang proses pengajuan', 'jenis_kelompok' => 'Kelompok Wanita Tani (KWT)', 'komoditas_id' => 1, 'sort_by' => 'anggota_desc', 'per_page' => 25]; $data = ($this->listKelompok)($filters); parse_str(parse_url($data['kelompokTani']->url(2), PHP_URL_QUERY), $params); foreach ($filters as $field => $value) expect((string) $params[$field])->toBe((string) $value); expect((int) $data['summary']->total_kelompok)->toBe(1); }); test('list cards filters columns and permissions match the group data', function (string $role, bool $showEnumerator) { $user = new User(['name' => 'Pengguna Uji', 'email' => 'user@example.test', 'role' => $role]); $user->id = 7; $this->be($user); $data = ($this->listKelompok)(['status_legalitas' => 'Sedang proses pengajuan']); $html = view('produktivitas.survey-kelompok-tani.index', $data)->render(); $dom = new DOMDocument(); libxml_use_internal_errors(true); $dom->loadHTML($html); libxml_clear_errors(); $xpath = new DOMXPath($dom); expect($xpath->query('//div[@id="kelompok-overview"]/div')->length)->toBe(4); foreach (['Kelompok Tani', 'Komoditas', 'Status SK', 'Pengurus', 'Anggota', 'Nama Responden'] as $heading) { expect($xpath->query('//th[normalize-space()="'.$heading.'"]')->length)->toBe(1); } expect($xpath->query('//th[normalize-space()="Nama Enumerator"]')->length)->toBe($showEnumerator ? 1 : 0); expect($data['showEnumerator'])->toBe($showEnumerator); expect($xpath->query('//*[@name="pernah_mendapatkan_bantuan"]')->length)->toBe(0); expect($xpath->query('//form[@id="kelompok-filter-form"]')->item(0)->getAttribute('data-desa-url'))->toContain(':id'); expect($xpath->query('//button[contains(@class,"delete-record")]')->item(0)->getAttribute('data-delete-url'))->toBe(route('kelompok-tani.destroy', 2)); expect($html)->toContain('kelompok-tani.js'); })->with([['admin', true], ['super_admin', true], ['enumerator', false]]); test('roles outside group management cannot request the list', function () { $user = new User(['name' => 'Fasilitator Uji', 'email' => 'fasilitator@example.test', 'role' => 'fasilitator']); $this->be($user); expect(fn () => ($this->listKelompok)())->toThrow(\Illuminate\Auth\Access\AuthorizationException::class); }); test('all standard type and SK choices remain available with no matching or recorded groups', function () { $data = ($this->listKelompok)(['search' => 'Tidak ada kelompok']); expect($data['kelompokTani']->total())->toBe(0); expect($data['jenisKelompokOptions']->all())->toBe([ 'Kelompok Tani (Poktan)', 'Kelompok Wanita Tani (KWT)', 'Gabungan Kelompok Tani (Gapoktan)', 'Kelompok Pemuda Tani', 'Asosiasi Petani', 'Lainnya', ]); expect($data['statusLegalitasOptions']->all())->toBe([ 'Belum ada SK/dokumen apa pun', 'Sedang proses pengajuan', 'Sudah ada SK dari desa/kecamatan', 'Sudah ada SK dan terdaftar di Dinas/Kabupaten', 'Tidak tahu', ]); DB::table('survey_kelompok_tani')->update(['deleted_at' => now()]); $empty = ($this->listKelompok)(); expect($empty['jenisKelompokOptions']->all())->toBe($data['jenisKelompokOptions']->all()); expect($empty['statusLegalitasOptions']->all())->toBe($data['statusLegalitasOptions']->all()); $html = view('produktivitas.survey-kelompok-tani.index', $empty)->render(); expect($html)->toContain('Gabungan Kelompok Tani (Gapoktan)', 'Tidak tahu'); }); test('Lainnya type filter includes custom descriptions and literal Lainnya', function () { DB::table('survey_kelompok_tani')->where('id', 3)->update(['jenis_kelompok' => 'Komunitas Petani Lokal']); DB::table('survey_kelompok_tani')->where('id', 2)->update(['jenis_kelompok' => 'Lainnya']); $data = ($this->listKelompok)(['jenis_kelompok' => 'Lainnya']); expect($data['kelompokTani']->pluck('id')->all())->toBe([3, 2]); expect((int) $data['summary']->total_kelompok)->toBe(2); }); test('removed aid filter neither constrains results nor persists in pagination', function () { $data = ($this->listKelompok)(['pernah_mendapatkan_bantuan' => '0']); expect($data['kelompokTani']->pluck('id')->all())->toBe([3, 2, 1]); expect($data['filters'])->not->toHaveKey('pernah_mendapatkan_bantuan'); expect($data['kelompokTani']->url(2))->not->toContain('pernah_mendapatkan_bantuan'); }); test('unfilled membership totals remain distinct from a recorded zero in the table', function () { $data = ($this->listKelompok)(['search' => 'Citra']); $html = view('produktivitas.survey-kelompok-tani.index', $data)->render(); $dom = new DOMDocument(); libxml_use_internal_errors(true); $dom->loadHTML($html); libxml_clear_errors(); $xpath = new DOMXPath($dom); expect(trim($xpath->query('//tr[contains(@class,"kelompok-tani-row")]/td[5]/div')->item(0)->textContent))->toBe('Belum terisi'); expect(trim($xpath->query('//tr[contains(@class,"kelompok-tani-row")]/td[6]/div')->item(0)->textContent))->toBe('0'); });