diff --git a/app/Http/Controllers/PublicController.php b/app/Http/Controllers/PublicController.php index ddc3d6d..52b8239 100644 --- a/app/Http/Controllers/PublicController.php +++ b/app/Http/Controllers/PublicController.php @@ -41,6 +41,8 @@ class PublicController extends Controller { abort_if($activity->status !== 'approved', 404); + $activity->load('creator', 'participants'); + return view('public.kegiatan-detail', compact('activity')); } @@ -55,6 +57,8 @@ class PublicController extends Controller { abort_if($post->status !== 'published' || ! $post->published_at, 404); + $post->load('author'); + return view('public.blog-detail', compact('post')); } diff --git a/app/Models/Approval.php b/app/Models/Approval.php index b9088a1..c2fbdf7 100644 --- a/app/Models/Approval.php +++ b/app/Models/Approval.php @@ -9,8 +9,6 @@ class Approval extends Model { protected $fillable = ['model_type', 'model_id', 'required_approvals', 'status']; - protected $with = ['items.user']; - public function items(): HasMany { return $this->hasMany(ApprovalItem::class); diff --git a/app/Observers/ActivityObserver.php b/app/Observers/ActivityObserver.php index 67952dc..1a158a8 100644 --- a/app/Observers/ActivityObserver.php +++ b/app/Observers/ActivityObserver.php @@ -12,25 +12,36 @@ use Illuminate\Support\Facades\Auth; class ActivityObserver { - public function updated(Activity $activity): void + public function updating(Activity $activity): bool { - if ($activity->wasChanged('status')) { + if ($activity->isDirty('status')) { $old = $activity->getOriginal('status'); $new = $activity->status; - // Validasi workflow: draft→pending, pending→approved/rejected $allowed = [ 'draft' => ['pending'], 'pending' => ['approved', 'rejected'], ]; - if (isset($allowed[$old]) && ! in_array($new, $allowed[$old])) { + $validTransition = isset($allowed[$old]) && in_array($new, $allowed[$old]); + + if (! $validTransition) { Notification::make()->title('Transisi tidak diizinkan') ->body("Status tidak bisa diubah dari {$old} ke {$new}.") ->danger()->send(); - $activity->status = $old; - return; + + return false; // batalkan save } + } + + return true; + } + + public function updated(Activity $activity): void + { + if ($activity->wasChanged('status')) { + $old = $activity->getOriginal('status'); + $new = $activity->status; ActivityLog::create([ 'user_id' => Auth::id(), diff --git a/app/Observers/CashRecordObserver.php b/app/Observers/CashRecordObserver.php index 043f515..55c3c03 100644 --- a/app/Observers/CashRecordObserver.php +++ b/app/Observers/CashRecordObserver.php @@ -23,12 +23,10 @@ class CashRecordObserver // Threshold: 500rb–2jt → buat approval ketua + notif if ($record->amount >= 500_000 && $record->amount <= 2_000_000) { - Approval::create([ - 'model_type' => CashRecord::class, - 'model_id' => $record->id, - 'required_approvals' => 1, - 'status' => 'pending', - ]); + Approval::firstOrCreate( + ['model_type' => CashRecord::class, 'model_id' => $record->id], + ['required_approvals' => 1, 'status' => 'pending'] + ); NotificationService::toRole('ketua', 'Transaksi Kas Butuh Persetujuan', @@ -38,7 +36,7 @@ class CashRecordObserver ); } - // Threshold: > 2jt → buat voting + notif semua anggota + // Threshold: > 2jt → buat voting (VoteObserver akan notif semua user) if ($record->amount > 2_000_000) { Vote::create([ 'title' => "Persetujuan Transaksi: {$record->description}", @@ -50,13 +48,6 @@ class CashRecordObserver 'deadline' => now()->addDays(3), 'created_by' => Auth::id() ?? $record->created_by, ]); - - NotificationService::toRole('ketua', - 'Voting Transaksi Besar Dibuat', - "Transaksi \"{$record->description}\" senilai Rp " . number_format($record->amount, 0, ',', '.') . " memerlukan voting.", - 'warning', - route('filament.admin.resources.votes.index') - ); } } @@ -76,13 +67,9 @@ class CashRecordObserver public function deleting(CashRecord $record): void { if ($record->verified_at !== null) { - \Filament\Notifications\Notification::make() - ->title('Tidak dapat dihapus') - ->body('Transaksi yang sudah diverifikasi tidak dapat dihapus.') - ->danger() - ->send(); - - abort(403); + throw new \Illuminate\Auth\Access\AuthorizationException( + 'Transaksi yang sudah diverifikasi tidak dapat dihapus.' + ); } } } diff --git a/app/Observers/UserObserver.php b/app/Observers/UserObserver.php index 3b11022..f224712 100644 --- a/app/Observers/UserObserver.php +++ b/app/Observers/UserObserver.php @@ -12,11 +12,6 @@ class UserObserver { public function updated(User $user): void { - // Pastikan role anggota selalu ada - if (! $user->hasRole('anggota')) { - $user->assignRole('anggota'); - } - // Log perubahan status anggota if ($user->wasChanged('status')) { MemberStatusLog::create([ diff --git a/app/Policies/CashRecordPolicy.php b/app/Policies/CashRecordPolicy.php index c5a8756..e2981ff 100644 --- a/app/Policies/CashRecordPolicy.php +++ b/app/Policies/CashRecordPolicy.php @@ -34,7 +34,7 @@ class CashRecordPolicy public function delete(AuthUser $authUser, CashRecord $cashRecord): bool { - return $authUser->can('Delete:CashRecord'); + return $authUser->can('Delete:CashRecord') && $cashRecord->verified_at === null; } public function deleteAny(AuthUser $authUser): bool diff --git a/tests/Feature/ActivityObserverTest.php b/tests/Feature/ActivityObserverTest.php new file mode 100644 index 0000000..6440211 --- /dev/null +++ b/tests/Feature/ActivityObserverTest.php @@ -0,0 +1,88 @@ +user = User::factory()->create(); + $this->actingAs($this->user); + } + + private function makeActivity(string $status = 'draft'): Activity + { + return Activity::create([ + 'title' => 'Test Kegiatan', + 'start_date' => now()->toDateString(), + 'end_date' => now()->addDay()->toDateString(), + 'created_by' => $this->user->id, + 'status' => $status, + ]); + } + + public function test_draft_can_transition_to_pending(): void + { + $activity = $this->makeActivity('draft'); + $activity->update(['status' => 'pending']); + + $this->assertSame('pending', $activity->fresh()->status); + } + + public function test_draft_cannot_skip_to_approved(): void + { + $activity = $this->makeActivity('draft'); + $activity->update(['status' => 'approved']); + + $this->assertSame('draft', $activity->fresh()->status); + } + + public function test_pending_can_transition_to_approved(): void + { + $activity = $this->makeActivity('pending'); + $activity->update(['status' => 'approved']); + + $this->assertSame('approved', $activity->fresh()->status); + } + + public function test_pending_can_transition_to_rejected(): void + { + $activity = $this->makeActivity('pending'); + $activity->update(['status' => 'rejected']); + + $this->assertSame('rejected', $activity->fresh()->status); + } + + public function test_approved_cannot_revert_to_draft(): void + { + $activity = $this->makeActivity('approved'); + $activity->update(['status' => 'draft']); + + $this->assertSame('approved', $activity->fresh()->status); + } + + public function test_large_budget_pending_creates_vote(): void + { + $activity = $this->makeActivity('draft'); + $activity->budget = 3_000_000; + $activity->save(); + + $activity->update(['status' => 'pending']); + + $this->assertDatabaseHas('votes', [ + 'related_type' => Activity::class, + 'related_id' => $activity->id, + 'type' => 'finance', + ]); + } +} diff --git a/tests/Feature/CashRecordObserverTest.php b/tests/Feature/CashRecordObserverTest.php new file mode 100644 index 0000000..83ea2ce --- /dev/null +++ b/tests/Feature/CashRecordObserverTest.php @@ -0,0 +1,89 @@ +user = User::factory()->create(); + $this->category = CashCategory::create(['name' => 'Umum']); + $this->actingAs($this->user); + } + + private function makeRecord(int $amount): CashRecord + { + return CashRecord::create([ + 'amount' => $amount, + 'category_id' => $this->category->id, + 'description' => 'Test', + 'date' => now()->toDateString(), + 'created_by' => $this->user->id, + ]); + } + + public function test_small_transaction_creates_no_approval_or_vote(): void + { + $record = $this->makeRecord(100_000); + + $this->assertDatabaseMissing('approvals', ['model_id' => $record->id]); + $this->assertDatabaseMissing('votes', ['related_id' => $record->id]); + } + + public function test_mid_transaction_creates_approval(): void + { + $record = $this->makeRecord(1_000_000); + + $this->assertDatabaseHas('approvals', [ + 'model_type' => CashRecord::class, + 'model_id' => $record->id, + 'status' => 'pending', + ]); + $this->assertDatabaseMissing('votes', ['related_id' => $record->id]); + } + + public function test_mid_transaction_does_not_duplicate_approval(): void + { + $record = $this->makeRecord(1_000_000); + // Simulate retry — observer called again + (new \App\Observers\CashRecordObserver)->created($record); + + $this->assertSame(1, Approval::where('model_id', $record->id)->count()); + } + + public function test_large_transaction_creates_vote(): void + { + $record = $this->makeRecord(3_000_000); + + $this->assertDatabaseHas('votes', [ + 'related_type' => CashRecord::class, + 'related_id' => $record->id, + 'type' => 'finance', + ]); + $this->assertDatabaseMissing('approvals', ['model_id' => $record->id]); + } + + public function test_verified_record_cannot_be_deleted(): void + { + $record = $this->makeRecord(100_000); + $record->update(['verified_by' => $this->user->id, 'verified_at' => now()]); + + $this->expectException(\Illuminate\Auth\Access\AuthorizationException::class); + + $record->delete(); + } +} diff --git a/tests/TestCase.php b/tests/TestCase.php index fe1ffc2..abcd20b 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -3,8 +3,16 @@ namespace Tests; use Illuminate\Foundation\Testing\TestCase as BaseTestCase; +use Spatie\Permission\Models\Role; abstract class TestCase extends BaseTestCase { - // + protected function setUp(): void + { + parent::setUp(); + // Seed roles yang dibutuhkan observer + foreach (['anggota', 'ketua', 'bendahara', 'pengurus', 'auditor', 'super_admin'] as $role) { + Role::firstOrCreate(['name' => $role, 'guard_name' => 'web']); + } + } }