feat: implement changelog management system with CRUD operations and role-based permissions

This commit is contained in:
Yoga Pangestu 2026-04-02 10:45:02 +07:00
parent 29ab36ba85
commit bccc36ca81
12 changed files with 806 additions and 0 deletions

View File

@ -0,0 +1,55 @@
<?php
namespace App\Enums;
use Filament\Support\Contracts\HasColor;
use Filament\Support\Contracts\HasIcon;
use Filament\Support\Contracts\HasLabel;
enum ChangelogType: string implements HasColor, HasIcon, HasLabel
{
case Feature = 'feature';
case Improvement = 'improvement';
case BugFix = 'bugfix';
case Security = 'security';
public function getLabel(): ?string
{
return match ($this) {
self::Feature => 'Fitur Baru 🚀',
self::Improvement => 'Peningkatan ✨',
self::BugFix => 'Perbaikan Bug 🐛',
self::Security => 'Keamanan 🔐',
};
}
public function getColor(): string|array|null
{
return match ($this) {
self::Feature => 'success',
self::Improvement => 'info',
self::BugFix => 'danger',
self::Security => 'warning',
};
}
public function getIcon(): ?string
{
return match ($this) {
self::Feature => 'heroicon-o-rocket-launch',
self::Improvement => 'heroicon-o-sparkles',
self::BugFix => 'heroicon-o-bug-ant',
self::Security => 'heroicon-o-shield-check',
};
}
public function getColorClass(): string
{
return match ($this) {
self::Feature => 'emerald',
self::Improvement => 'blue',
self::BugFix => 'rose',
self::Security => 'amber',
};
}
}

View File

@ -0,0 +1,32 @@
<?php
namespace App\Filament\Pages\System\Changelog\Actions;
use App\Filament\Actions\Cheerful\CreateAction;
use App\Models\Changelog;
use Filament\Support\Enums\Width;
class CreateChangelogAction extends CreateAction
{
public static function getDefaultName(): string
{
return 'createChangelog';
}
protected function setUp(): void
{
parent::setUp();
$this->model(Changelog::class)
->label('Tambah')
->modalHeading('Tambah Riwayat')
->modalSubmitActionLabel('Simpan')
->modalCancelActionLabel('Batal')
->extraModalFooterActions(fn (CreateAction $action): array => [
$action->makeModalSubmitAction('createAnother', arguments: ['another' => true])
->label('Simpan dan Tambah Lagi'),
])
->modalWidth(Width::ThreeExtraLarge)
->schema(fn ($livewire) => $livewire->changelogFormSchema());
}
}

View File

@ -0,0 +1,30 @@
<?php
namespace App\Filament\Pages\System\Changelog\Actions;
use App\Filament\Actions\Cheerful\DeleteAction;
use App\Models\Changelog;
class DeleteChangelogAction extends DeleteAction
{
public static function getDefaultName(): string
{
return 'deleteChangelog';
}
protected function setUp(): void
{
parent::setUp();
$this->record(fn (array $arguments): Changelog => Changelog::findOrFail($arguments['record']))
->label('Hapus')
->icon('heroicon-o-trash')
->color('danger')
->link()
->tooltip('Hapus')
->modalHeading('Hapus Data')
->modalDescription('Apakah Anda yakin ingin menghapus riwayat ini? Tindakan ini tidak dapat dibatalkan.')
->modalSubmitActionLabel('Hapus')
->modalCancelActionLabel('Batal');
}
}

View File

@ -0,0 +1,34 @@
<?php
namespace App\Filament\Pages\System\Changelog\Actions;
use App\Filament\Actions\Cheerful\EditAction;
use App\Models\Changelog;
use Filament\Support\Enums\Width;
class EditChangelogAction extends EditAction
{
public static function getDefaultName(): string
{
return 'editChangelog';
}
protected function setUp(): void
{
parent::setUp();
$this
->label('Ubah')
->color('warning')
->icon('heroicon-o-pencil-square')
->link()
->tooltip('Ubah')
->record(fn (array $arguments): Changelog => Changelog::findOrFail($arguments['record']))
->modalHeading('Ubah Data')
->modalSubmitActionLabel('Simpan')
->modalCancelActionLabel('Batal')
->modalWidth(Width::ThreeExtraLarge)
->schema(fn ($livewire) => $livewire->changelogFormSchema())
->fillForm(fn (Changelog $record): array => $record->toArray());
}
}

View File

@ -0,0 +1,150 @@
<?php
namespace App\Filament\Pages\System\Changelog;
use App\Enums\ChangelogType;
use App\Filament\Pages\System\Changelog\Actions\CreateChangelogAction;
use App\Filament\Pages\System\Changelog\Actions\DeleteChangelogAction;
use App\Filament\Pages\System\Changelog\Actions\EditChangelogAction;
use App\Filament\Support\SystemNotification;
use App\Models\Changelog;
use BackedEnum;
use BezhanSalleh\FilamentShield\Traits\HasPageShield;
use Filament\Actions\Action;
use Filament\Actions\Concerns\InteractsWithActions;
use Filament\Actions\Contracts\HasActions;
use Filament\Forms\Components\DatePicker;
use Filament\Forms\Components\RichEditor;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TagsInput;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Concerns\InteractsWithForms;
use Filament\Forms\Contracts\HasForms;
use Filament\Pages\Page;
use Filament\Schemas\Components\Grid;
use Illuminate\Support\Collection;
use Livewire\Attributes\Computed;
use UnitEnum;
class Index extends Page implements HasActions, HasForms
{
use HasPageShield, InteractsWithActions, InteractsWithForms;
protected static string|UnitEnum|null $navigationGroup = 'Sistem';
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-information-circle';
protected static ?string $title = 'Changelog';
protected static ?string $navigationLabel = 'Changelog';
protected static ?string $slug = 'system/changelog';
protected static ?int $navigationSort = 10;
protected string $view = 'filament.pages.system.changelog.index';
public static function getPagePermission(): string
{
return 'View:Changelog';
}
#[Computed]
public function techStack(): array
{
$latestUpdate = Changelog::latest('release_date')->first();
return [
'name' => config('app.name'),
'version' => $latestUpdate?->version ?? 'v1.0.0',
'stack' => [
'PHP' => PHP_VERSION,
'Laravel' => app()->version(),
'Filament' => 'v5.x',
'Database' => config('database.default'),
],
];
}
#[Computed]
public function changelogs(): Collection
{
return Changelog::latest('release_date')->get();
}
protected function getHeaderActions(): array
{
return [
CreateChangelogAction::make()
->visible(fn () => auth()->user()?->can('Create:Changelog')),
];
}
public function editChangelogAction(): Action
{
return EditChangelogAction::make()
->visible(fn () => auth()->user()?->can('Update:Changelog'));
}
public function deleteChangelogAction(): Action
{
return DeleteChangelogAction::make()
->visible(fn () => auth()->user()?->can('Delete:Changelog'));
}
#[Computed]
public function emptyHeading(): string
{
return SystemNotification::getByKey('labels.empty_changelog.title');
}
#[Computed]
public function emptyDescription(): string
{
return SystemNotification::getByKey('labels.empty_changelog.description');
}
public function changelogFormSchema(): array
{
return [
Grid::make(3)
->schema([
TextInput::make('version')
->label('Versi')
->placeholder('v1.0.0')
->required()
->unique(ignoreRecord: true),
DatePicker::make('release_date')
->label('Tanggal Rilis')
->required()
->native(false)
->default(now())
->displayFormat('d F Y'),
Select::make('type')
->label('Tipe Update')
->options(ChangelogType::class)
->required()
->native(false),
]),
TextInput::make('title')
->label('Judul Update')
->placeholder('Sistem Notifikasi & Estetika Baru')
->required()
->maxLength(100)
->autocomplete(false),
TagsInput::make('changes')
->label('Daftar Perubahan')
->placeholder('Tambah perubahan...')
->required(),
RichEditor::make('description')
->label('Deskripsi')
->placeholder('...')
->required(),
];
}
}

44
app/Models/Changelog.php Normal file
View File

@ -0,0 +1,44 @@
<?php
namespace App\Models;
use App\Enums\ChangelogType;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
class Changelog extends Model
{
use HasFactory, SoftDeletes;
protected $fillable = [
'version',
'release_date',
'title',
'changes',
'type',
'description',
];
/**
* The attributes that should be cast.
*
* @var array<string, string>
*/
protected $casts = [
'release_date' => 'date',
'changes' => 'array',
'type' => ChangelogType::class,
];
public function formattedVersion(): Attribute
{
return Attribute::get(fn () => strtoupper($this->version));
}
public function formattedReleaseDate(): Attribute
{
return Attribute::get(fn () => $this->release_date->translatedFormat('l, d M Y'));
}
}

View File

@ -0,0 +1,37 @@
<?php
namespace Database\Factories;
use App\Enums\ChangelogType;
use App\Models\Changelog;
use Illuminate\Database\Eloquent\Factories\Factory;
class ChangelogFactory extends Factory
{
protected $model = Changelog::class;
public function definition(): array
{
static $patchNumber = 0;
$patchNumber++;
return [
'version' => "v1.0.{$patchNumber}",
'release_date' => $this->faker->dateTimeBetween('-1 year', 'now')->format('Y-m-d'),
'title' => $this->faker->sentence(4),
'changes' => $this->faker->sentences(3),
'type' => $this->faker->randomElement(ChangelogType::cases()),
'description' => $this->faker->paragraph(),
];
}
public function feature(): static
{
return $this->state(['type' => ChangelogType::Feature]);
}
public function bugfix(): static
{
return $this->state(['type' => ChangelogType::BugFix]);
}
}

View File

@ -0,0 +1,36 @@
<?php
use App\Enums\ChangelogType;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('changelogs', function (Blueprint $table) {
$table->id();
$table->string('version')->unique();
$table->date('release_date');
$table->string('title', 100);
$table->json('changes');
$table->enum('type', ChangelogType::cases());
$table->text('description');
$table->timestamp('created_at')->useCurrent();
$table->timestamp('updated_at')->nullable()->useCurrentOnUpdate();
$table->softDeletes();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('changelogs');
}
};

View File

@ -23,6 +23,11 @@ public function run(): void
"name": "Developer",
"guard_name": "web",
"permissions" : [
"View:Changelog",
"Create:Changelog",
"Update:Changelog",
"Delete:Changelog",
"Create:Assignment",
"Delete:Assignment",
"ForceDelete:Assignment",
@ -152,6 +157,8 @@ public function run(): void
"name": "Mahasiswa",
"guard_name": "web",
"permissions" : [
"View:Changelog",
"View:Assignment",
"ViewAny:Assignment",
@ -175,6 +182,8 @@ public function run(): void
"name": "Kosma",
"guard_name": "web",
"permissions" : [
"View:Changelog",
"Create:Assignment",
"Delete:Assignment",
"ForceDelete:Assignment",

View File

@ -193,6 +193,14 @@
'title' => 'Daftar Tugas Bikin Lemes! 🤣📒',
'description' => 'Yuk, cek dan kumpulin tugasmu biar tenang hidupnya! Klik aja di tugasnya ya. 🚀👨‍💻',
],
'changelog' => [
'title' => 'Catatan Rilis 📜✨',
'description' => 'Kepoin apa aja yang baru, fitur kece, dan perbaikan bug biar nggak kudet! 🚀🔥',
],
'empty_changelog' => [
'title' => 'Belum Ada Catatan Rilis 📭',
'description' => 'Belum ada update yang tercatat nih. Buat mulai catat riwayat pembaruan pertama! 🚀',
],
],
// Icons
@ -398,6 +406,14 @@
'title' => 'Tugas Saya',
'description' => 'Klik pada tugas untuk melihat detail dan mengumpulkan file.',
],
'changelog' => [
'title' => 'Catatan Rilis',
'description' => 'Lihat riwayat perbaikan bug, pembaruan aplikasi, dan rilis fitur terbaru secara mendalam.',
],
'empty_changelog' => [
'title' => 'Tidak Ada Riwayat Pembaruan',
'description' => 'Belum ada catatan rilis yang tersedia. Buat untuk mendokumentasikan pembaruan aplikasi.',
],
],
// Icons

View File

@ -0,0 +1,100 @@
<x-filament-panels::page>
<x-filament::section>
<div class="flex items-center gap-4 mb-6">
<div class="p-3 rounded-xl bg-primary-100 dark:bg-primary-500/20 text-primary-600 dark:text-primary-400">
<x-heroicon-o-rocket-launch class="w-8 h-8" />
</div>
<div>
<h2 class="text-2xl font-bold tracking-tight text-gray-950 dark:text-white">
{{ $this->techStack['name'] }}
</h2>
<p class="text-sm text-gray-500 dark:text-gray-400">
Versi Aplikasi:
<span class="font-semibold text-primary-600 dark:text-primary-400">
{{ $this->techStack['version'] }}
</span>
</p>
</div>
</div>
<div class="grid grid-cols-2 sm:grid-cols-4 gap-4 pt-4 border-t border-gray-100 dark:border-white/5">
@foreach ($this->techStack['stack'] as $tech => $version)
<div class="p-3 rounded-lg bg-gray-50 dark:bg-white/5 border border-gray-100 dark:border-white/10 transition hover:border-primary-500/30">
<p class="text-[10px] uppercase tracking-wider font-bold text-gray-400 dark:text-gray-500">
{{ $tech }}
</p>
<p class="text-sm font-semibold text-gray-700 dark:text-gray-300 truncate">
{{ $version }}
</p>
</div>
@endforeach
</div>
</x-filament::section>
<x-filament::section>
@if (count($this->changelogs))
<div class="space-y-4">
@foreach ($this->changelogs as $log)
<div class="fi-card rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 shadow-sm hover:shadow-md transition">
<div class="p-5 space-y-4">
<div class="flex items-start justify-between gap-3">
<div class="flex flex-wrap items-center gap-2">
<span class="inline-flex items-center rounded-md bg-{{ $log->type->getColorClass() }}-50 dark:bg-{{ $log->type->getColorClass() }}-500/10 px-2 py-1 text-xs font-semibold text-{{ $log->type->getColorClass() }}-700 dark:text-{{ $log->type->getColorClass() }}-300 ring-1 ring-inset ring-{{ $log->type->getColorClass() }}-600/20">
{{ $log->type->getLabel() }}
</span>
<span class="inline-flex items-center rounded-md bg-gray-50 dark:bg-gray-800/50 px-2 py-1 text-xs font-bold text-gray-600 dark:text-gray-300 ring-1 ring-inset ring-gray-200 dark:ring-gray-700">
{{ $log->formatted_version }}
</span>
</div>
<span class="inline-flex items-center text-xs text-gray-500 dark:text-gray-400 shrink-0">
<x-heroicon-m-calendar class="w-4 h-4 mr-1 opacity-70" />
{{ $log->formatted_release_date }}
</span>
</div>
<h3 class="text-base font-semibold leading-tight text-gray-950 dark:text-white">
{{ $log->title }}
</h3>
@if ($log->description)
<div class="text-sm text-gray-500 dark:text-gray-400 prose prose-sm dark:prose-invert max-w-none">
{!! $log->description !!}
</div>
@endif
<div>
<span class="text-[10px] font-bold uppercase text-gray-400 tracking-widest leading-none">
Daftar Perubahan
</span>
<ul class="mt-2 space-y-1.5">
@foreach ($log->changes as $change)
<li class="flex items-start gap-2 text-sm text-gray-600 dark:text-gray-400">
<x-heroicon-m-check-circle class="w-4 h-4 mt-0.5 shrink-0 text-{{ $log->type->getColorClass() }}-500" />
<span>{{ $change }}</span>
</li>
@endforeach
</ul>
</div>
</div>
@canAny(['Update:Changelog', 'Delete:Changelog'])
<div class="flex items-center justify-end gap-1 px-5 pb-4 pt-3 border-t border-gray-100 dark:border-gray-800">
{{ ($this->editChangelogAction)(['record' => $log->id]) }}
{{ ($this->deleteChangelogAction)(['record' => $log->id]) }}
</div>
@endcanAny
</div>
@endforeach
</div>
@else
<x-filament::empty-state
icon="heroicon-o-information-circle"
heading="{{ $this->emptyHeading }}"
description="{{ $this->emptyDescription }}"
iconColor="gray">
</x-filament::empty-state>
@endif
</x-filament::section>
</x-filament-panels::page>

View File

@ -0,0 +1,263 @@
<?php
use App\Enums\ChangelogType;
use App\Enums\RoleEnum;
use App\Filament\Pages\System\Changelog\Index as ChangelogPage;
use App\Models\Changelog;
use App\Models\User;
use Illuminate\Database\QueryException;
use Illuminate\Support\Carbon;
use Livewire\Livewire;
use Spatie\Permission\Models\Permission;
use Spatie\Permission\Models\Role;
/**
* Setup necessary roles and permissions
*/
beforeEach(function () {
Role::findOrCreate(RoleEnum::Developer->value);
Role::findOrCreate(RoleEnum::Kosma->value);
Role::findOrCreate(RoleEnum::Student->value);
$permissions = [
'View:Changelog',
'Create:Changelog',
'Update:Changelog',
'Delete:Changelog',
];
foreach ($permissions as $permission) {
Permission::findOrCreate($permission);
}
});
describe('Changelog Authorization', function () {
it('allows Developer to access the changelog page', function () {
$developer = User::factory()->create();
$developer->assignRole(RoleEnum::Developer);
$developer->givePermissionTo('View:Changelog');
$this->actingAs($developer);
Livewire::test(ChangelogPage::class)
->assertSuccessful();
});
it('restricts Students from accessing the changelog page', function () {
$student = User::factory()->create();
$student->assignRole(RoleEnum::Student);
$this->actingAs($student);
Livewire::test(ChangelogPage::class)
->assertStatus(403);
});
it('shows createChangelog action to users with Create:Changelog permission', function () {
$developer = User::factory()->create();
$developer->assignRole(RoleEnum::Developer);
$developer->givePermissionTo(['View:Changelog', 'Create:Changelog']);
$this->actingAs($developer);
Livewire::test(ChangelogPage::class)
->assertActionVisible('createChangelog');
});
it('hides createChangelog action from users without Create:Changelog permission', function () {
$kosma = User::factory()->create();
$kosma->assignRole(RoleEnum::Kosma);
$kosma->givePermissionTo('View:Changelog');
$this->actingAs($kosma);
Livewire::test(ChangelogPage::class)
->assertActionHidden('createChangelog');
});
});
describe('Changelog Model', function () {
it('has the correct casts configured', function () {
$changelog = Changelog::factory()->create([
'type' => ChangelogType::Feature,
'changes' => ['Tambah fitur baru', 'Update UI'],
]);
expect($changelog->type)->toBeInstanceOf(ChangelogType::class);
expect($changelog->type)->toBe(ChangelogType::Feature);
expect($changelog->changes)->toBeArray();
expect($changelog->release_date)->toBeInstanceOf(Carbon::class);
});
it('supports soft deletes', function () {
$changelog = Changelog::factory()->create();
$changelog->delete();
expect(Changelog::find($changelog->id))->toBeNull();
expect(Changelog::withTrashed()->find($changelog->id))->not->toBeNull();
expect($changelog->refresh()->trashed())->toBeTrue();
});
it('can be force deleted', function () {
$changelog = Changelog::factory()->create();
$changelog->delete();
$changelog->forceDelete();
expect(Changelog::withTrashed()->find($changelog->id))->toBeNull();
$this->assertDatabaseMissing('changelogs', ['id' => $changelog->id]);
});
it('enforces unique version constraint', function () {
Changelog::factory()->create(['version' => 'v1.0.0']);
expect(fn () => Changelog::factory()->create(['version' => 'v1.0.0']))
->toThrow(QueryException::class);
});
});
describe('Changelog CRUD via Filament Page', function () {
beforeEach(function () {
$user = User::factory()->create();
$user->assignRole(RoleEnum::Developer);
$user->givePermissionTo(['View:Changelog', 'Create:Changelog', 'Update:Changelog', 'Delete:Changelog']);
$this->actingAs($user);
});
it('can create a changelog entry', function () {
Livewire::test(ChangelogPage::class)
->callAction('createChangelog', [
'version' => 'v2.0.0',
'release_date' => now()->toDateString(),
'type' => ChangelogType::Feature->value,
'title' => 'Fitur Keren Baru',
'changes' => ['Tambah fitur A', 'Tambah fitur B'],
'description' => 'Deskripsi singkat fitur baru.',
])
->assertHasNoActionErrors();
$this->assertDatabaseHas('changelogs', [
'version' => 'v2.0.0',
'title' => 'Fitur Keren Baru',
]);
});
it('validates required fields when creating a changelog', function () {
Livewire::test(ChangelogPage::class)
->callAction('createChangelog', [
'version' => '',
'title' => '',
])
->assertHasActionErrors(['version', 'title']);
});
it('validates unique version when creating a changelog', function () {
Changelog::factory()->create(['version' => 'v1.5.0']);
Livewire::test(ChangelogPage::class)
->callAction('createChangelog', [
'version' => 'v1.5.0',
'release_date' => now()->toDateString(),
'type' => ChangelogType::BugFix->value,
'title' => 'Duplikat Versi',
'changes' => ['Fix A'],
'description' => 'Deskripsi.',
])
->assertHasActionErrors(['version']);
});
it('can edit an existing changelog entry', function () {
$changelog = Changelog::factory()->create([
'title' => 'Judul Lama',
'type' => ChangelogType::BugFix,
'changes' => ['Fix satu bug'],
]);
Livewire::test(ChangelogPage::class)
->callAction('editChangelog', [
'version' => $changelog->version,
'release_date' => $changelog->release_date->toDateString(),
'type' => ChangelogType::Improvement->value,
'title' => 'Judul Baru',
'changes' => ['Perbaikan performa'],
'description' => $changelog->description,
], ['record' => $changelog->id])
->assertHasNoActionErrors();
expect($changelog->refresh()->title)->toBe('Judul Baru');
expect($changelog->refresh()->type)->toBe(ChangelogType::Improvement);
});
it('can delete a changelog entry (soft delete)', function () {
$changelog = Changelog::factory()->create();
Livewire::test(ChangelogPage::class)
->callAction('deleteChangelog', [], ['record' => $changelog->id])
->assertHasNoActionErrors();
$this->assertSoftDeleted('changelogs', ['id' => $changelog->id]);
});
});
describe('Changelog Computed Properties', function () {
beforeEach(function () {
$user = User::factory()->create();
$user->assignRole(RoleEnum::Developer);
$user->givePermissionTo('View:Changelog');
$this->actingAs($user);
});
it('returns changelogs sorted by latest release date', function () {
$oldest = Changelog::factory()->create(['release_date' => now()->subYear()]);
$newest = Changelog::factory()->create(['release_date' => now()]);
$component = Livewire::test(ChangelogPage::class);
// changelogs() computed prop returns latest first
$ids = Changelog::latest('release_date')->pluck('id')->toArray();
expect($ids[0])->toBe($newest->id);
expect($ids[1])->toBe($oldest->id);
});
it('reflects latest version in techStack', function () {
// Clear and create changelogs in order
Changelog::factory()->create(['version' => 'v1.0.0', 'release_date' => now()->subDays(5)]);
Changelog::factory()->create(['version' => 'v2.0.0', 'release_date' => now()]);
$latestVersion = Changelog::latest('release_date')->first()->version;
expect($latestVersion)->toBe('v2.0.0');
});
it('shows changelog entries on the page', function () {
$changelog = Changelog::factory()->create(['title' => 'Update Kece']);
Livewire::test(ChangelogPage::class)
->assertSee('Update Kece');
});
it('shows empty state message when no changelogs exist', function () {
Changelog::query()->forceDelete();
// The page should still render without errors when empty
Livewire::test(ChangelogPage::class)
->assertSuccessful();
});
});
describe('ChangelogType Enum', function () {
it('returns correct label for each type', function () {
expect(ChangelogType::Feature->getLabel())->toContain('Fitur Baru');
expect(ChangelogType::Improvement->getLabel())->toContain('Peningkatan');
expect(ChangelogType::BugFix->getLabel())->toContain('Perbaikan Bug');
expect(ChangelogType::Security->getLabel())->toContain('Keamanan');
});
it('returns correct color for each type', function () {
expect(ChangelogType::Feature->getColor())->toBe('success');
expect(ChangelogType::Improvement->getColor())->toBe('info');
expect(ChangelogType::BugFix->getColor())->toBe('danger');
expect(ChangelogType::Security->getColor())->toBe('warning');
});
it('returns correct icon for each type', function () {
expect(ChangelogType::Feature->getIcon())->toBe('heroicon-o-rocket-launch');
expect(ChangelogType::BugFix->getIcon())->toBe('heroicon-o-bug-ant');
expect(ChangelogType::Security->getIcon())->toBe('heroicon-o-shield-check');
});
});