refactor observers, fix policy, add feature tests
This commit is contained in:
@@ -41,6 +41,8 @@ class PublicController extends Controller
|
|||||||
{
|
{
|
||||||
abort_if($activity->status !== 'approved', 404);
|
abort_if($activity->status !== 'approved', 404);
|
||||||
|
|
||||||
|
$activity->load('creator', 'participants');
|
||||||
|
|
||||||
return view('public.kegiatan-detail', compact('activity'));
|
return view('public.kegiatan-detail', compact('activity'));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,6 +57,8 @@ class PublicController extends Controller
|
|||||||
{
|
{
|
||||||
abort_if($post->status !== 'published' || ! $post->published_at, 404);
|
abort_if($post->status !== 'published' || ! $post->published_at, 404);
|
||||||
|
|
||||||
|
$post->load('author');
|
||||||
|
|
||||||
return view('public.blog-detail', compact('post'));
|
return view('public.blog-detail', compact('post'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,8 +9,6 @@ class Approval extends Model
|
|||||||
{
|
{
|
||||||
protected $fillable = ['model_type', 'model_id', 'required_approvals', 'status'];
|
protected $fillable = ['model_type', 'model_id', 'required_approvals', 'status'];
|
||||||
|
|
||||||
protected $with = ['items.user'];
|
|
||||||
|
|
||||||
public function items(): HasMany
|
public function items(): HasMany
|
||||||
{
|
{
|
||||||
return $this->hasMany(ApprovalItem::class);
|
return $this->hasMany(ApprovalItem::class);
|
||||||
|
|||||||
@@ -12,25 +12,36 @@ use Illuminate\Support\Facades\Auth;
|
|||||||
|
|
||||||
class ActivityObserver
|
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');
|
$old = $activity->getOriginal('status');
|
||||||
$new = $activity->status;
|
$new = $activity->status;
|
||||||
|
|
||||||
// Validasi workflow: draft→pending, pending→approved/rejected
|
|
||||||
$allowed = [
|
$allowed = [
|
||||||
'draft' => ['pending'],
|
'draft' => ['pending'],
|
||||||
'pending' => ['approved', 'rejected'],
|
'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')
|
Notification::make()->title('Transisi tidak diizinkan')
|
||||||
->body("Status tidak bisa diubah dari {$old} ke {$new}.")
|
->body("Status tidak bisa diubah dari {$old} ke {$new}.")
|
||||||
->danger()->send();
|
->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([
|
ActivityLog::create([
|
||||||
'user_id' => Auth::id(),
|
'user_id' => Auth::id(),
|
||||||
|
|||||||
@@ -23,12 +23,10 @@ class CashRecordObserver
|
|||||||
|
|
||||||
// Threshold: 500rb–2jt → buat approval ketua + notif
|
// Threshold: 500rb–2jt → buat approval ketua + notif
|
||||||
if ($record->amount >= 500_000 && $record->amount <= 2_000_000) {
|
if ($record->amount >= 500_000 && $record->amount <= 2_000_000) {
|
||||||
Approval::create([
|
Approval::firstOrCreate(
|
||||||
'model_type' => CashRecord::class,
|
['model_type' => CashRecord::class, 'model_id' => $record->id],
|
||||||
'model_id' => $record->id,
|
['required_approvals' => 1, 'status' => 'pending']
|
||||||
'required_approvals' => 1,
|
);
|
||||||
'status' => 'pending',
|
|
||||||
]);
|
|
||||||
|
|
||||||
NotificationService::toRole('ketua',
|
NotificationService::toRole('ketua',
|
||||||
'Transaksi Kas Butuh Persetujuan',
|
'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) {
|
if ($record->amount > 2_000_000) {
|
||||||
Vote::create([
|
Vote::create([
|
||||||
'title' => "Persetujuan Transaksi: {$record->description}",
|
'title' => "Persetujuan Transaksi: {$record->description}",
|
||||||
@@ -50,13 +48,6 @@ class CashRecordObserver
|
|||||||
'deadline' => now()->addDays(3),
|
'deadline' => now()->addDays(3),
|
||||||
'created_by' => Auth::id() ?? $record->created_by,
|
'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
|
public function deleting(CashRecord $record): void
|
||||||
{
|
{
|
||||||
if ($record->verified_at !== null) {
|
if ($record->verified_at !== null) {
|
||||||
\Filament\Notifications\Notification::make()
|
throw new \Illuminate\Auth\Access\AuthorizationException(
|
||||||
->title('Tidak dapat dihapus')
|
'Transaksi yang sudah diverifikasi tidak dapat dihapus.'
|
||||||
->body('Transaksi yang sudah diverifikasi tidak dapat dihapus.')
|
);
|
||||||
->danger()
|
|
||||||
->send();
|
|
||||||
|
|
||||||
abort(403);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,11 +12,6 @@ class UserObserver
|
|||||||
{
|
{
|
||||||
public function updated(User $user): void
|
public function updated(User $user): void
|
||||||
{
|
{
|
||||||
// Pastikan role anggota selalu ada
|
|
||||||
if (! $user->hasRole('anggota')) {
|
|
||||||
$user->assignRole('anggota');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Log perubahan status anggota
|
// Log perubahan status anggota
|
||||||
if ($user->wasChanged('status')) {
|
if ($user->wasChanged('status')) {
|
||||||
MemberStatusLog::create([
|
MemberStatusLog::create([
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ class CashRecordPolicy
|
|||||||
|
|
||||||
public function delete(AuthUser $authUser, CashRecord $cashRecord): bool
|
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
|
public function deleteAny(AuthUser $authUser): bool
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use App\Models\Activity;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class ActivityObserverTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
private User $user;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
$this->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',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use App\Models\Approval;
|
||||||
|
use App\Models\CashCategory;
|
||||||
|
use App\Models\CashRecord;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\Vote;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class CashRecordObserverTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
private User $user;
|
||||||
|
private CashCategory $category;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
$this->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();
|
||||||
|
}
|
||||||
|
}
|
||||||
+9
-1
@@ -3,8 +3,16 @@
|
|||||||
namespace Tests;
|
namespace Tests;
|
||||||
|
|
||||||
use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
|
use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
|
||||||
|
use Spatie\Permission\Models\Role;
|
||||||
|
|
||||||
abstract class TestCase extends BaseTestCase
|
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']);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user