diff --git a/app/Http/Controllers/Master/ArtikelKabupatenController.php b/app/Http/Controllers/Master/ArtikelKabupatenController.php new file mode 100644 index 00000000..c32eea6b --- /dev/null +++ b/app/Http/Controllers/Master/ArtikelKabupatenController.php @@ -0,0 +1,49 @@ +generateListPermission(); + $clearCache = request('clear_cache', false); + if ($clearCache) { + (new \App\Services\ArtikelService)->clearCache('artikel', ['filter[id]' => $clearCache]); + } + + return view('master.artikel.index')->with($listPermission); + } + + /** + * Show the form for creating a new resource. + * + * @return \Illuminate\Http\Response + */ + public function create(): View + { + return view('master.artikel.create'); + } + + /** + * Show the form for editing the specified resource. + * + * @param int $id + * + * @return \Illuminate\Http\Response + */ + public function edit($id): View + { + return view('master.artikel.edit', compact('id')); + } +} diff --git a/app/Http/Controllers/Master/ArtikelUploadController.php b/app/Http/Controllers/Master/ArtikelUploadController.php new file mode 100644 index 00000000..40cdeaad --- /dev/null +++ b/app/Http/Controllers/Master/ArtikelUploadController.php @@ -0,0 +1,51 @@ +pathFolder = 'uploads/artikel'; + } + + /** + * Upload gambar artikel + */ + public function uploadGambar(Request $request) + { + try { + $request->validate([ + 'file' => 'required|image|mimes:jpg,jpeg,png,gif|max:2048', + ]); + + if ($request->file('file')) { + $path = $this->uploadFile($request, 'file'); + $url = Storage::url($path); + + return response()->json([ + 'success' => true, + 'url' => $url, + 'path' => $path, + ], 200); + } + + return response()->json([ + 'success' => false, + 'message' => 'File tidak ditemukan', + ], 400); + } catch (\Exception $e) { + return response()->json([ + 'success' => false, + 'message' => 'Upload gagal: ' . $e->getMessage(), + ], 500); + } + } +} diff --git a/app/Services/ArtikelService.php b/app/Services/ArtikelService.php new file mode 100644 index 00000000..5b899677 --- /dev/null +++ b/app/Services/ArtikelService.php @@ -0,0 +1,48 @@ +buildCacheKey('artikel', $filters); + + // Ambil dari cache dulu + return Cache::remember($cacheKey, $this->cacheTtl, function () use ($filters) { + $data = $this->apiRequest('/api/v1/artikel', $filters); + if (! $data) { + return collect([]); + } + + return collect($data)->map(fn ($item) => (object) $item['attributes']); + }); + } + + public function artikelById(int $id) + { + $cacheKey = "artikel_$id"; + + return Cache::remember($cacheKey, $this->cacheTtl, function () use ($id) { + $data = $this->apiRequest('/api/v1/artikel/tampil', [ + 'id' => $id, + ]); + + if (is_array($data) && isset($data['data'])) { + return (object) $data['data']; + } + + return null; + }); + } + + public function clearCache(string $prefix = 'artikel', array $filters = []) + { + $cacheKey = $this->buildCacheKey($prefix, $filters); + Cache::forget($cacheKey); + } +} diff --git a/catatan_rilis.md b/catatan_rilis.md index e21b49a3..152fbb9e 100644 --- a/catatan_rilis.md +++ b/catatan_rilis.md @@ -2,6 +2,7 @@ Di rilis ini, versi 2512.0.1 berisi penambahan dan perbaikan yang diminta penggu #### Penambahan Fitur +1. [#872](https://github.com/OpenSID/OpenKab/issues/872) Penambahan modul artikel OpenSID. #### Perbaikan BUG diff --git a/database/migrations/2025_12_09_101721_add_menu_artikel.php b/database/migrations/2025_12_09_101721_add_menu_artikel.php new file mode 100644 index 00000000..70e8867d --- /dev/null +++ b/database/migrations/2025_12_09_101721_add_menu_artikel.php @@ -0,0 +1,114 @@ +menu; + + // Cari menu "Pengaturan OpenSID" + foreach ($menu as $key => $menuItem) { + if ($menuItem['text'] === 'Pengaturan OpenSID') { + // Cari posisi "Kategori Artikel" + $submenu = $menuItem['submenu']; + $kategoriIndex = null; + + foreach ($submenu as $index => $sub) { + if ($sub['text'] === 'Kategori Artikel') { + $kategoriIndex = $index; + break; + } + } + + // Jika Kategori Artikel ditemukan, tambahkan Artikel setelahnya + if ($kategoriIndex !== null) { + $newSubmenu = [ + 'icon' => 'far fa-fw fa-circle', + 'text' => 'Artikel', + 'url' => 'master/artikel', + 'permission' => 'master-data-artikel', + ]; + + // Insert setelah Kategori Artikel + array_splice($submenu, $kategoriIndex + 1, 0, [$newSubmenu]); + $menu[$key]['submenu'] = $submenu; + } + + break; + } + } + + // Update menu team + $team->update(['menu' => $menu]); + } + + // Tidak perlu membuat permission baru karena sudah ada 'master-data-artikel' + // Permission ini sudah digunakan untuk kategori artikel + + // Sync permission ke role yang sudah ada + $roles = Role::all(); + foreach ($roles as $role) { + // Pastikan role punya permission master-data-artikel + if (!$role->hasPermissionTo('master-data-artikel-read')) { + $permissions = [ + 'master-data-artikel-read', + 'master-data-artikel-write', + 'master-data-artikel-edit', + 'master-data-artikel-delete', + ]; + + foreach ($permissions as $permissionName) { + $permission = Permission::where('name', $permissionName)->first(); + if ($permission && !$role->hasPermissionTo($permission)) { + $role->givePermissionTo($permission); + } + } + } + } + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + // Remove menu artikel dari semua team + $teams = Team::all(); + + foreach ($teams as $team) { + $menu = $team->menu; + + foreach ($menu as $key => $menuItem) { + if ($menuItem['text'] === 'Pengaturan OpenSID') { + $submenu = $menuItem['submenu']; + + // Hapus menu Artikel + $submenu = array_filter($submenu, function($sub) { + return $sub['text'] !== 'Artikel'; + }); + + $menu[$key]['submenu'] = array_values($submenu); + break; + } + } + + $team->update(['menu' => $menu]); + } + } +}; diff --git a/resources/views/master/artikel/create.blade.php b/resources/views/master/artikel/create.blade.php new file mode 100644 index 00000000..48736ff3 --- /dev/null +++ b/resources/views/master/artikel/create.blade.php @@ -0,0 +1,331 @@ +@extends('layouts.index') + +@section('title', 'Tambah Artikel') + +@section('content_header') +

Tambah Artikel

+@stop + +@section('content') + @include('partials.breadcrumbs') +
+
+
+ +
+
+
+ +
+ +
+ + +
+ + +
+ + +
+
+ + +
+ +
+
+
+ Gambar Utama +
+ +
+
+ + +
+ + +
+ + +
+ + +
+
+
+
+
+ +
+
+
+@endsection + +@section('css') + +@stop + +@section('js') + @include('partials.asset_tinymce') + +@endsection diff --git a/resources/views/master/artikel/edit.blade.php b/resources/views/master/artikel/edit.blade.php new file mode 100644 index 00000000..63fd6dbd --- /dev/null +++ b/resources/views/master/artikel/edit.blade.php @@ -0,0 +1,392 @@ +@extends('layouts.index') + +@section('title', 'Edit Artikel') + +@section('content_header') +

Edit Artikel

+@stop + +@section('content') + @include('partials.breadcrumbs') +
+
+
+ +
+
+
+ +
+ +
+ + +
+ + +
+ + +
+
+ + +
+ +
+
+
+ Gambar Utama +
+ +
+
+ + +
+ + +
+ + +
+
+ + +
+
+ + +
+ + +
+
+
+
+
+ +
+
+
+@endsection + +@section('css') + +@stop + +@section('js') + @include('partials.asset_tinymce') + +@endsection diff --git a/resources/views/master/artikel/index.blade.php b/resources/views/master/artikel/index.blade.php new file mode 100644 index 00000000..1b94b599 --- /dev/null +++ b/resources/views/master/artikel/index.blade.php @@ -0,0 +1,205 @@ +@extends('layouts.index') + +@section('title', 'Data Artikel') + +@section('content_header') +

Data Artikel

+@stop + +@section('content') + @include('partials.breadcrumbs') +
+
+
+
+ @if ($canwrite) +
+
+ Tambah +
+
+ @endif +
+
+
+ + + + + + + + + + + + +
NoAksiJudulKategoriTanggal UploadStatus
+
+
+
+
+
+@endsection + +@section('js') + +@endsection diff --git a/routes/breadcrumbs.php b/routes/breadcrumbs.php index a142cbb4..ffa611a1 100644 --- a/routes/breadcrumbs.php +++ b/routes/breadcrumbs.php @@ -10,6 +10,7 @@ use App\Models\Setting; use App\Models\Team; use App\Models\User; +use App\Services\ArtikelService; use App\Services\BantuanService; use App\Services\KategoriService; use App\Services\PendudukApiService; @@ -57,6 +58,18 @@ $trail->push($name); }); +Breadcrumbs::for('master-data-artikel.index', function (BreadcrumbTrail $trail) { + $trail->push('Master Artikel', route('master-data-artikel.index')); +}); +Breadcrumbs::for('master-data-artikel.create', function (BreadcrumbTrail $trail) { + $trail->parent('master-data-artikel.index'); + $trail->push('Baru'); +}); +Breadcrumbs::for('master-data-artikel.edit', function (BreadcrumbTrail $trail, $id) { + $trail->parent('master-data-artikel.index'); + $trail->push('Edit Artikel'); +}); + Breadcrumbs::for('users.index', function (BreadcrumbTrail $trail) { $trail->push('Pengaturan Pengguna', route('users.index')); }); diff --git a/routes/web.php b/routes/web.php index 2a7e5d2b..e3b3e83d 100644 --- a/routes/web.php +++ b/routes/web.php @@ -13,6 +13,8 @@ use App\Http\Controllers\KecamatanController; use App\Http\Controllers\KeluargaController; use App\Http\Controllers\LaporanBulananController; +use App\Http\Controllers\Master\ArtikelKabupatenController; +use App\Http\Controllers\Master\ArtikelUploadController; use App\Http\Controllers\Master\BantuanKabupatenController; use App\Http\Controllers\PendudukController; use App\Http\Controllers\PlanController; @@ -253,6 +255,8 @@ Route::prefix('master') ->group(function () { Route::middleware(['easyauthorize:master-data-bantuan'])->resource('bantuan', BantuanKabupatenController::class)->only(['index', 'create', 'edit']); + Route::middleware(['easyauthorize:master-data-artikel'])->resource('artikel', ArtikelKabupatenController::class)->names('master-data-artikel')->only(['index', 'create', 'edit']); + Route::post('artikel/upload-gambar', [ArtikelUploadController::class, 'uploadGambar'])->name('artikel.upload_gambar'); Route::controller(AdminWebController::class)->group(function () { Route::middleware(['permission:master-data-artikel-read'])->get('/kategori/{parrent}', 'kategori_index')->name('master-data-artikel.kategori'); Route::middleware(['permission:master-data-artikel-edit'])->get('/kategori/edit/{id}/{parrent}', 'kategori_edit')->name('master-data-artikel.kategori-edit'); diff --git a/tests/Feature/ArtikelControllerTest.php b/tests/Feature/ArtikelControllerTest.php new file mode 100644 index 00000000..723ed355 --- /dev/null +++ b/tests/Feature/ArtikelControllerTest.php @@ -0,0 +1,136 @@ +get(route('master-data-artikel.index')); + $response->assertStatus(200); + $response->assertViewIs('master.artikel.index'); + $response->assertViewHas(['canwrite', 'canedit', 'candelete']); + $response->assertSee('Data Artikel'); + } + + /** @test */ + public function it_can_access_artikel_create() + { + $response = $this->get(route('master-data-artikel.create')); + $response->assertStatus(200); + $response->assertViewIs('master.artikel.create'); + $response->assertSee('Tambah Artikel'); + $response->assertSee('Judul Artikel'); + $response->assertSee('Isi Artikel'); + $response->assertSee('Kategori'); + } + + /** @test */ + public function it_can_access_artikel_edit() + { + $artikelId = 1; // ID artikel untuk testing + $response = $this->get(route('master-data-artikel.edit', ['artikel' => $artikelId])); + $response->assertStatus(200); + $response->assertViewIs('master.artikel.edit'); + $response->assertViewHas('id', $artikelId); + $response->assertSee('Edit Artikel'); + } + + /** @test */ + public function artikel_index_requires_authentication() + { + auth()->logout(); + $response = $this->get(route('master-data-artikel.index')); + $response->assertRedirect(route('login')); + } + + /** @test */ + public function artikel_create_requires_authentication() + { + auth()->logout(); + $response = $this->get(route('master-data-artikel.create')); + $response->assertRedirect(route('login')); + } + + /** @test */ + public function artikel_edit_requires_authentication() + { + auth()->logout(); + $response = $this->get(route('master-data-artikel.edit', ['artikel' => 1])); + $response->assertRedirect(route('login')); + } + + /** @test */ + public function it_can_access_upload_gambar_route() + { + $response = $this->post(route('artikel.upload_gambar'), [ + '_token' => csrf_token(), + ]); + + // Tanpa file, bisa return validation error (302) atau error lain + $this->assertContains($response->status(), [302, 400, 500]); + } + + /** @test */ + public function artikel_index_shows_proper_table_structure() + { + $response = $this->get(route('master-data-artikel.index')); + $response->assertStatus(200); + + // Check table headers + $response->assertSee('No'); + $response->assertSee('Aksi'); + $response->assertSee('Judul'); + $response->assertSee('Kategori'); + $response->assertSee('Tanggal Upload'); + $response->assertSee('Status'); + } + + /** @test */ + public function artikel_create_shows_required_fields() + { + $response = $this->get(route('master-data-artikel.create')); + $response->assertStatus(200); + + // Check required fields marked with asterisk + $response->assertSee('Judul Artikel'); + $response->assertSee('Isi Artikel'); + $response->assertSee('Kategori'); + $response->assertSee('text-danger'); // asterisk styling + } + + /** @test */ + public function artikel_create_has_upload_functionality() + { + $response = $this->get(route('master-data-artikel.create')); + $response->assertStatus(200); + + // Check upload elements + $response->assertSee('upload_gambar'); + $response->assertSee('Gambar Utama'); + } + + /** @test */ + public function artikel_edit_loads_with_id() + { + $artikelId = 123; + $response = $this->get(route('master-data-artikel.edit', ['artikel' => $artikelId])); + $response->assertStatus(200); + + // Check if ID is passed to view + $response->assertViewHas('id', $artikelId); + $response->assertSee((string) $artikelId); + } + + /** @test */ + public function artikel_routes_are_registered() + { + // Check if routes exist + $this->assertTrue(route('master-data-artikel.index') !== null); + $this->assertTrue(route('master-data-artikel.create') !== null); + $this->assertTrue(route('artikel.upload_gambar') !== null); + } +} diff --git a/tests/Feature/ArtikelUploadTest.php b/tests/Feature/ArtikelUploadTest.php new file mode 100644 index 00000000..4e31502c --- /dev/null +++ b/tests/Feature/ArtikelUploadTest.php @@ -0,0 +1,158 @@ +image('test-artikel.jpg', 800, 600); + + $response = $this->post(route('artikel.upload_gambar'), [ + 'file' => $file, + ]); + + $response->assertStatus(200); + $response->assertJson([ + 'success' => true, + ]); + $response->assertJsonStructure([ + 'success', + 'url', + 'path', + ]); + } + + /** @test */ + public function it_rejects_non_image_files() + { + $file = UploadedFile::fake()->create('document.pdf', 100); + + $response = $this->post(route('artikel.upload_gambar'), [ + 'file' => $file, + ]); + + // Should not return success + $this->assertNotEquals(200, $response->status()); + } + + /** @test */ + public function it_rejects_files_larger_than_2mb() + { + $file = UploadedFile::fake()->image('large-image.jpg')->size(3000); // 3MB + + $response = $this->post(route('artikel.upload_gambar'), [ + 'file' => $file, + ]); + + // Should not return success + $this->assertNotEquals(200, $response->status()); + } + + /** @test */ + public function it_accepts_jpg_format() + { + $file = UploadedFile::fake()->image('test.jpg'); + + $response = $this->post(route('artikel.upload_gambar'), [ + 'file' => $file, + ]); + + $response->assertStatus(200); + $response->assertJsonFragment(['success' => true]); + } + + /** @test */ + public function it_accepts_jpeg_format() + { + $file = UploadedFile::fake()->image('test.jpeg'); + + $response = $this->post(route('artikel.upload_gambar'), [ + 'file' => $file, + ]); + + $response->assertStatus(200); + $response->assertJsonFragment(['success' => true]); + } + + /** @test */ + public function it_accepts_png_format() + { + $file = UploadedFile::fake()->image('test.png'); + + $response = $this->post(route('artikel.upload_gambar'), [ + 'file' => $file, + ]); + + $response->assertStatus(200); + $response->assertJsonFragment(['success' => true]); + } + + /** @test */ + public function it_requires_file_parameter() + { + $response = $this->post(route('artikel.upload_gambar'), []); + + // Should return error (not success) + $this->assertContains($response->status(), [302, 400, 500]); + } + + /** @test */ + public function upload_returns_valid_url() + { + $file = UploadedFile::fake()->image('artikel-gambar.jpg'); + + $response = $this->post(route('artikel.upload_gambar'), [ + 'file' => $file, + ]); + + $response->assertStatus(200); + $data = $response->json(); + + $this->assertArrayHasKey('url', $data); + $this->assertIsString($data['url']); + $this->assertNotEmpty($data['url']); + } + + /** @test */ + public function upload_returns_valid_path() + { + $file = UploadedFile::fake()->image('artikel-gambar.jpg'); + + $response = $this->post(route('artikel.upload_gambar'), [ + 'file' => $file, + ]); + + $response->assertStatus(200); + $data = $response->json(); + + $this->assertArrayHasKey('path', $data); + $this->assertIsString($data['path']); + $this->assertStringContainsString('uploads/artikel', $data['path']); + } + + /** @test */ + public function upload_requires_authentication() + { + auth()->logout(); + + $file = UploadedFile::fake()->image('test.jpg'); + + $response = $this->post(route('artikel.upload_gambar'), [ + 'file' => $file, + ]); + + $response->assertRedirect(route('login')); + } +} diff --git a/tests/Unit/ArtikelServiceTest.php b/tests/Unit/ArtikelServiceTest.php new file mode 100644 index 00000000..9a3a2fb5 --- /dev/null +++ b/tests/Unit/ArtikelServiceTest.php @@ -0,0 +1,90 @@ +service = new ArtikelService(); + } + + /** @test */ + public function it_can_instantiate_artikel_service() + { + $this->assertInstanceOf(ArtikelService::class, $this->service); + } + + /** @test */ + public function it_builds_cache_key_correctly() + { + $reflection = new \ReflectionClass($this->service); + $method = $reflection->getMethod('buildCacheKey'); + $method->setAccessible(true); + + $cacheKey = $method->invokeArgs($this->service, ['artikel', ['id' => 1]]); + + $this->assertIsString($cacheKey); + $this->assertStringContainsString('artikel', $cacheKey); + } + + /** @test */ + public function clear_cache_removes_cached_data() + { + $cacheKey = 'test_artikel_cache'; + Cache::put($cacheKey, 'test_data', 3600); + + $this->assertTrue(Cache::has($cacheKey)); + + Cache::forget($cacheKey); + + $this->assertFalse(Cache::has($cacheKey)); + } + + /** @test */ + public function it_has_cache_ttl_property() + { + $reflection = new \ReflectionClass($this->service); + $property = $reflection->getProperty('cacheTtl'); + $property->setAccessible(true); + + $ttl = $property->getValue($this->service); + + $this->assertEquals(3600, $ttl); + $this->assertIsInt($ttl); + } + + /** @test */ + public function artikel_method_returns_collection() + { + // Mock API response untuk testing + // Note: Ini membutuhkan mock untuk API request + // Untuk test sederhana, kita hanya check method exists + $this->assertTrue(method_exists($this->service, 'artikel')); + } + + /** @test */ + public function artikel_by_id_method_exists() + { + $this->assertTrue(method_exists($this->service, 'artikelById')); + } + + /** @test */ + public function clear_cache_method_exists() + { + $this->assertTrue(method_exists($this->service, 'clearCache')); + } + + /** @test */ + public function service_extends_base_api_service() + { + $this->assertInstanceOf(\App\Services\BaseApiService::class, $this->service); + } +}