Add Access group admin surfaces, booking soft deletes, refund crash fix

Access group (Filament):
- StaffResource: manage users with an admin-tier role, gated by manage_staff
- CustomerResource: read-only view of role-less users, gated by view_customers
- RoleResource: edit permissions per role (fixed role set), gated by manage_roles
- ManageAppSettings: tabbed General/Booking settings page that reads/writes
  real .env keys via new EnvFileWriter (no parallel DB settings table, so
  BookingService/config('booking.*') stay unchanged)
- Moved Access above Catalog in the nav group order
- New permissions: manage_staff, manage_roles, view_customers, manage_settings

Booking soft deletes:
- bookings.deleted_at + SoftDeletes on the Booking model
- BookingPolicy::delete (manage_bookings, cancelled/expired only) and
  ::restore (manage_bookings)
- DeleteBookingTableAction/RestoreBookingTableAction + TrashedFilter on
  BookingsTable, using authorize() so the policy is enforced at call time,
  not just cosmetically hidden

Refund crash fix:
- ProcessRefundAction passed a nullable $payment->booking into
  RefundBookingAction's non-nullable Booking param — a soft-deleted
  booking's payment reaching the refund picker was an uncaught TypeError.
  Excluded such payments from the picker and added a defensive guard.
- Same unguarded $event->payment->booking / $event->refund->payment->booking
  pattern fixed in the MarkBookingPaid/MarkBookingRefunded queued listeners.

289 tests passing.
This commit is contained in:
Nyan Lin Paing
2026-08-09 23:21:11 +07:00
parent 46f9b8d5a3
commit fd3a195453
43 changed files with 1450 additions and 3 deletions
@@ -21,6 +21,10 @@ class RolePermissionSeeder extends Seeder
'view_payments',
'process_refunds',
'view_audit_log',
'manage_staff',
'manage_roles',
'view_customers',
'manage_settings',
];
/**
@@ -36,6 +40,10 @@ class RolePermissionSeeder extends Seeder
'view_payments',
'process_refunds',
'view_audit_log',
'manage_staff',
'manage_roles',
'view_customers',
'manage_settings',
],
'admin' => [
'manage_catalog',
@@ -46,11 +54,14 @@ class RolePermissionSeeder extends Seeder
'view_payments',
'process_refunds',
'view_audit_log',
'view_customers',
'manage_settings',
],
'support' => [
'view_bookings',
'view_payments',
'view_audit_log',
'view_customers',
],
];
@@ -0,0 +1,3 @@
<x-filament-panels::page>
{{ $this->form }}
</x-filament-panels::page>
@@ -0,0 +1,151 @@
<?php
namespace Modules\Identity\Filament\Pages;
use BackedEnum;
use Filament\Actions\Action;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Notifications\Notification;
use Filament\Pages\Page;
use Filament\Schemas\Components\Actions;
use Filament\Schemas\Components\Form;
use Filament\Schemas\Components\Tabs;
use Filament\Schemas\Components\Tabs\Tab;
use Filament\Schemas\Schema;
use Filament\Support\Icons\Heroicon;
use Illuminate\Support\Facades\Artisan;
use Modules\Shared\Support\EnvFileWriter;
use UnitEnum;
/**
* Edits real env-backed config values (config('app.*'), config('booking.*'))
* in place via EnvFileWriter, rather than introducing a parallel DB-backed
* settings table so BookingService and everything else that already reads
* config('booking.*') keeps working unchanged (domain.md §2).
*
* Requires the .env file to be writable by the app process; if it isn't
* (e.g. some production containers ship a read-only filesystem), saving
* will throw and the admin needs to edit .env directly on that host instead.
*/
class ManageAppSettings extends Page
{
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedCog6Tooth;
protected static string|UnitEnum|null $navigationGroup = 'Access';
protected static ?string $navigationLabel = 'App Settings';
protected static ?string $title = 'App Settings';
protected string $view = 'identity::filament.pages.manage-app-settings';
/**
* @var array<string, mixed>|null
*/
public ?array $data = [];
public static function canAccess(): bool
{
return auth()->user()?->can('manage_settings') ?? false;
}
public function mount(): void
{
$this->form->fill([
'site_name' => config('app.name'),
'support_email' => config('app.support_email'),
'support_phone' => config('app.support_phone'),
'timezone' => config('app.timezone'),
'currency' => config('app.currency'),
'back_seat_enabled' => (bool) config('booking.back_seat_enabled'),
'whole_vehicle_enabled' => (bool) config('booking.whole_vehicle_enabled'),
'front_seat_max_per_booking' => config('booking.front_seat_max_per_booking'),
]);
}
public function form(Schema $schema): Schema
{
return $schema
->components([
Form::make([
Tabs::make('Settings')
->tabs([
Tab::make('General')
->schema([
TextInput::make('site_name')
->label('Site Name')
->required()
->maxLength(255),
TextInput::make('support_email')
->label('Support Email')
->email()
->maxLength(255),
TextInput::make('support_phone')
->label('Support Phone')
->tel()
->maxLength(255),
TextInput::make('timezone')
->label('Timezone')
->required()
->maxLength(64)
->helperText('A valid PHP timezone identifier, e.g. Asia/Yangon.'),
TextInput::make('currency')
->label('Currency Code')
->required()
->maxLength(3)
->helperText('ISO 4217 currency code, e.g. MMK.'),
])
->columns(2),
Tab::make('Booking')
->schema([
Toggle::make('back_seat_enabled')
->label('Back Seat Enabled')
->helperText('Whether customers can select Back Seat at all right now.'),
Toggle::make('whole_vehicle_enabled')
->label('Whole Vehicle Enabled')
->helperText('Whether customers can select Whole Vehicle at all right now.'),
TextInput::make('front_seat_max_per_booking')
->label('Front Seat Max Per Booking')
->numeric()
->minValue(1)
->required()
->helperText('Max Front Seats a single booking may request.'),
]),
]),
])
->livewireSubmitHandler('save')
->footer([
Actions::make([
Action::make('save')
->submit('save')
->keyBindings(['mod+s']),
]),
]),
])
->statePath('data');
}
public function save(EnvFileWriter $writer): void
{
$state = $this->form->getState();
$writer->write([
'APP_NAME' => $state['site_name'],
'SUPPORT_EMAIL' => $state['support_email'],
'SUPPORT_PHONE' => $state['support_phone'],
'APP_TIMEZONE' => $state['timezone'],
'APP_CURRENCY' => $state['currency'],
'BOOKING_BACK_SEAT_ENABLED' => (bool) $state['back_seat_enabled'],
'BOOKING_WHOLE_VEHICLE_ENABLED' => (bool) $state['whole_vehicle_enabled'],
'BOOKING_FRONT_SEAT_MAX_PER_BOOKING' => (int) $state['front_seat_max_per_booking'],
]);
Artisan::call('config:clear');
Notification::make()
->title('Settings saved')
->success()
->send();
}
}
@@ -0,0 +1,63 @@
<?php
namespace Modules\Identity\Filament\Resources\Customers;
use App\Models\User;
use BackedEnum;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Support\Icons\Heroicon;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Modules\Identity\Filament\Resources\Customers\Pages\ListCustomers;
use Modules\Identity\Filament\Resources\Customers\Pages\ViewCustomer;
use Modules\Identity\Filament\Resources\Customers\Schemas\CustomerInfolist;
use Modules\Identity\Filament\Resources\Customers\Tables\CustomersTable;
use UnitEnum;
/**
* Customers are `users` rows carrying no role at all the inverse scope of
* StaffResource (domain.md §4, single `users` table). Read-only by design:
* customer accounts are created via the mini app/mobile token flow (T1.2),
* never hand-entered by staff.
*/
class CustomerResource extends Resource
{
protected static ?string $model = User::class;
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedUsers;
protected static string|UnitEnum|null $navigationGroup = 'Access';
protected static ?string $navigationLabel = 'Customers';
protected static ?string $modelLabel = 'Customer';
public static function getEloquentQuery(): Builder
{
return parent::getEloquentQuery()->doesntHave('roles');
}
public static function table(Table $table): Table
{
return CustomersTable::configure($table);
}
public static function infolist(Schema $schema): Schema
{
return CustomerInfolist::configure($schema);
}
public static function getPages(): array
{
return [
'index' => ListCustomers::route('/'),
'view' => ViewCustomer::route('/{record}'),
];
}
public static function canViewAny(): bool
{
return auth()->user()?->can('view_customers') ?? false;
}
}
@@ -0,0 +1,18 @@
<?php
namespace Modules\Identity\Filament\Resources\Customers\Pages;
use Filament\Resources\Pages\ListRecords;
use Modules\Identity\Filament\Resources\Customers\CustomerResource;
class ListCustomers extends ListRecords
{
protected static string $resource = CustomerResource::class;
protected function getHeaderActions(): array
{
// No CreateAction — customer accounts are created via the mini
// app/mobile token flow (T1.2), never hand-entered here.
return [];
}
}
@@ -0,0 +1,11 @@
<?php
namespace Modules\Identity\Filament\Resources\Customers\Pages;
use Filament\Resources\Pages\ViewRecord;
use Modules\Identity\Filament\Resources\Customers\CustomerResource;
class ViewCustomer extends ViewRecord
{
protected static string $resource = CustomerResource::class;
}
@@ -0,0 +1,30 @@
<?php
namespace Modules\Identity\Filament\Resources\Customers\Schemas;
use Filament\Infolists\Components\TextEntry;
use Filament\Schemas\Components\Grid;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Schema;
class CustomerInfolist
{
public static function configure(Schema $schema): Schema
{
return $schema
->components([
Section::make('Customer')
->schema([
Grid::make(3)
->schema([
TextEntry::make('name'),
TextEntry::make('email'),
TextEntry::make('created_at')->label('Joined')->dateTime(),
TextEntry::make('bookings_count')->label('Total Bookings')->state(
fn ($record) => $record->bookings()->count(),
),
]),
]),
]);
}
}
@@ -0,0 +1,35 @@
<?php
namespace Modules\Identity\Filament\Resources\Customers\Tables;
use Filament\Actions\ViewAction;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
class CustomersTable
{
public static function configure(Table $table): Table
{
return $table
->modifyQueryUsing(fn (Builder $query) => $query->withCount('bookings'))
->defaultSort('created_at', 'desc')
->columns([
TextColumn::make('name')
->searchable()
->sortable(),
TextColumn::make('email')
->searchable()
->sortable(),
TextColumn::make('bookings_count')
->label('Bookings'),
TextColumn::make('created_at')
->label('Joined')
->dateTime()
->sortable(),
])
->recordActions([
ViewAction::make(),
]);
}
}
@@ -0,0 +1,28 @@
<?php
namespace Modules\Identity\Filament\Resources\Roles\Pages;
use Filament\Resources\Pages\EditRecord;
use Modules\Identity\Filament\Resources\Roles\RoleResource;
use Spatie\Permission\PermissionRegistrar;
class EditRole extends EditRecord
{
protected static string $resource = RoleResource::class;
protected function getHeaderActions(): array
{
// No DeleteAction — the role set is fixed (see RoleResource docblock).
return [];
}
/**
* Spatie caches resolved permissions per-request/process without
* this, a permission just toggled here wouldn't take effect until the
* cache naturally expires (RolePermissionSeeder does the same flush).
*/
protected function afterSave(): void
{
app(PermissionRegistrar::class)->forgetCachedPermissions();
}
}
@@ -0,0 +1,17 @@
<?php
namespace Modules\Identity\Filament\Resources\Roles\Pages;
use Filament\Resources\Pages\ListRecords;
use Modules\Identity\Filament\Resources\Roles\RoleResource;
class ListRoles extends ListRecords
{
protected static string $resource = RoleResource::class;
protected function getHeaderActions(): array
{
// No CreateAction — the role set is fixed (see RoleResource docblock).
return [];
}
}
@@ -0,0 +1,71 @@
<?php
namespace Modules\Identity\Filament\Resources\Roles;
use BackedEnum;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Support\Icons\Heroicon;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Model;
use Modules\Identity\Filament\Resources\Roles\Pages\EditRole;
use Modules\Identity\Filament\Resources\Roles\Pages\ListRoles;
use Modules\Identity\Filament\Resources\Roles\Schemas\RoleForm;
use Modules\Identity\Filament\Resources\Roles\Tables\RolesTable;
use Spatie\Permission\Models\Role;
use UnitEnum;
/**
* Deliberately no create/delete role names (super_admin/admin/support)
* are hardcoded across policies, User::ADMIN_TIER_ROLES, and the panel
* login gate (domain.md §4, §8), so the role set itself must stay fixed.
* Only what each role can do (its permissions) is editable here.
*/
class RoleResource extends Resource
{
protected static ?string $model = Role::class;
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedShieldCheck;
protected static string|UnitEnum|null $navigationGroup = 'Access';
protected static ?string $navigationLabel = 'Roles';
public static function form(Schema $schema): Schema
{
return RoleForm::configure($schema);
}
public static function table(Table $table): Table
{
return RolesTable::configure($table);
}
public static function getPages(): array
{
return [
'index' => ListRoles::route('/'),
'edit' => EditRole::route('/{record}/edit'),
];
}
public static function canViewAny(): bool
{
return auth()->user()?->can('manage_roles') ?? false;
}
public static function canEdit(Model $record): bool
{
return auth()->user()?->can('manage_roles') ?? false;
}
public static function canCreate(): bool
{
return false;
}
public static function canDelete(Model $record): bool
{
return false;
}
}
@@ -0,0 +1,25 @@
<?php
namespace Modules\Identity\Filament\Resources\Roles\Schemas;
use Filament\Forms\Components\CheckboxList;
use Filament\Forms\Components\TextInput;
use Filament\Schemas\Schema;
class RoleForm
{
public static function configure(Schema $schema): Schema
{
return $schema
->components([
TextInput::make('name')
->disabled()
->dehydrated(false),
CheckboxList::make('permissions')
->relationship(name: 'permissions', titleAttribute: 'name')
->columns(2)
->bulkToggleable()
->columnSpanFull(),
]);
}
}
@@ -0,0 +1,29 @@
<?php
namespace Modules\Identity\Filament\Resources\Roles\Tables;
use Filament\Actions\EditAction;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
class RolesTable
{
public static function configure(Table $table): Table
{
return $table
->modifyQueryUsing(fn (Builder $query) => $query->withCount(['permissions', 'users']))
->columns([
TextColumn::make('name')
->badge()
->sortable(),
TextColumn::make('permissions_count')
->label('Permissions'),
TextColumn::make('users_count')
->label('Staff'),
])
->recordActions([
EditAction::make(),
]);
}
}
@@ -0,0 +1,11 @@
<?php
namespace Modules\Identity\Filament\Resources\Staff\Pages;
use Filament\Resources\Pages\CreateRecord;
use Modules\Identity\Filament\Resources\Staff\StaffResource;
class CreateStaff extends CreateRecord
{
protected static string $resource = StaffResource::class;
}
@@ -0,0 +1,19 @@
<?php
namespace Modules\Identity\Filament\Resources\Staff\Pages;
use Filament\Actions\DeleteAction;
use Filament\Resources\Pages\EditRecord;
use Modules\Identity\Filament\Resources\Staff\StaffResource;
class EditStaff extends EditRecord
{
protected static string $resource = StaffResource::class;
protected function getHeaderActions(): array
{
return [
DeleteAction::make(),
];
}
}
@@ -0,0 +1,19 @@
<?php
namespace Modules\Identity\Filament\Resources\Staff\Pages;
use Filament\Actions\CreateAction;
use Filament\Resources\Pages\ListRecords;
use Modules\Identity\Filament\Resources\Staff\StaffResource;
class ListStaff extends ListRecords
{
protected static string $resource = StaffResource::class;
protected function getHeaderActions(): array
{
return [
CreateAction::make(),
];
}
}
@@ -0,0 +1,45 @@
<?php
namespace Modules\Identity\Filament\Resources\Staff\Schemas;
use App\Models\User;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Schemas\Schema;
use Illuminate\Support\Facades\Hash;
class StaffForm
{
public static function configure(Schema $schema): Schema
{
return $schema
->components([
TextInput::make('name')
->required()
->maxLength(255),
TextInput::make('email')
->required()
->email()
->unique(ignoreRecord: true)
->maxLength(255),
TextInput::make('password')
->password()
->revealable()
->required(fn (string $operation) => $operation === 'create')
->minLength(8)
->dehydrateStateUsing(fn (?string $state) => filled($state) ? Hash::make($state) : null)
->dehydrated(fn (?string $state) => filled($state))
->helperText('Leave blank to keep the current password.'),
Select::make('roles')
->relationship(
name: 'roles',
titleAttribute: 'name',
modifyQueryUsing: fn ($query) => $query->whereIn('name', User::ADMIN_TIER_ROLES),
)
->multiple()
->preload()
->required()
->helperText('Determines whether this staff member can sign in here at all, and what they can do.'),
]);
}
}
@@ -0,0 +1,90 @@
<?php
namespace Modules\Identity\Filament\Resources\Staff;
use App\Models\User;
use BackedEnum;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Support\Icons\Heroicon;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Modules\Identity\Filament\Resources\Staff\Pages\CreateStaff;
use Modules\Identity\Filament\Resources\Staff\Pages\EditStaff;
use Modules\Identity\Filament\Resources\Staff\Pages\ListStaff;
use Modules\Identity\Filament\Resources\Staff\Schemas\StaffForm;
use Modules\Identity\Filament\Resources\Staff\Tables\StaffTable;
use UnitEnum;
/**
* Staff and Customer both read from the single `users` table (domain.md §4
* no separate tables, no auth-guard split); this resource scopes to users
* carrying an admin-tier role, the same set that can sign in to this panel
* at all (User::ADMIN_TIER_ROLES). Gated by manage_staff deliberately
* separate from manage_roles (T7.x follow-up decision): granting someone
* access to the panel is a different, more sensitive action than editing
* what a role can do.
*/
class StaffResource extends Resource
{
protected static ?string $model = User::class;
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedUserGroup;
protected static string|UnitEnum|null $navigationGroup = 'Access';
protected static ?string $navigationLabel = 'Staff';
protected static ?string $modelLabel = 'Staff Member';
public static function getEloquentQuery(): Builder
{
return parent::getEloquentQuery()->role(User::ADMIN_TIER_ROLES);
}
public static function form(Schema $schema): Schema
{
return StaffForm::configure($schema);
}
public static function table(Table $table): Table
{
return StaffTable::configure($table);
}
public static function getPages(): array
{
return [
'index' => ListStaff::route('/'),
'create' => CreateStaff::route('/create'),
'edit' => EditStaff::route('/{record}/edit'),
];
}
public static function canViewAny(): bool
{
return auth()->user()?->can('manage_staff') ?? false;
}
public static function canCreate(): bool
{
return auth()->user()?->can('manage_staff') ?? false;
}
public static function canEdit(Model $record): bool
{
return auth()->user()?->can('manage_staff') ?? false;
}
/**
* Blocks the one obviously destructive foot-gun (a staff member
* deleting their own account and locking themselves out) on top of the
* manage_staff permission check.
*/
public static function canDelete(Model $record): bool
{
return (auth()->user()?->can('manage_staff') ?? false)
&& auth()->id() !== $record->getKey();
}
}
@@ -0,0 +1,37 @@
<?php
namespace Modules\Identity\Filament\Resources\Staff\Tables;
use Filament\Actions\DeleteAction;
use Filament\Actions\EditAction;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
class StaffTable
{
public static function configure(Table $table): Table
{
return $table
->modifyQueryUsing(fn (Builder $query) => $query->with('roles'))
->defaultSort('created_at', 'desc')
->columns([
TextColumn::make('name')
->searchable()
->sortable(),
TextColumn::make('email')
->searchable()
->sortable(),
TextColumn::make('roles.name')
->label('Roles')
->badge(),
TextColumn::make('created_at')
->dateTime()
->sortable(),
])
->recordActions([
EditAction::make(),
DeleteAction::make(),
]);
}
}
@@ -0,0 +1,35 @@
<?php
use App\Models\User;
use Livewire\Livewire;
use Modules\Identity\Database\Seeders\RolePermissionSeeder;
use Modules\Identity\Filament\Resources\Customers\Pages\ListCustomers;
beforeEach(function () {
$this->seed(RolePermissionSeeder::class);
$this->staff = User::factory()->create();
$this->staff->assignRole('support');
});
test('staff with view_customers can list customers', function () {
$this->actingAs($this->staff)->get('/admin/customers')->assertSuccessful();
});
test('the customer resource only lists users without any role', function () {
$customer = User::factory()->create();
$this->actingAs($this->staff);
Livewire::test(ListCustomers::class)
->assertCanSeeTableRecords([$customer])
->assertCanNotSeeTableRecords([$this->staff]);
});
test('a customer view page loads for staff', function () {
$customer = User::factory()->create();
$this->actingAs($this->staff)
->get("/admin/customers/{$customer->id}")
->assertSuccessful();
});
@@ -0,0 +1,71 @@
<?php
use App\Models\User;
use Livewire\Livewire;
use Modules\Identity\Database\Seeders\RolePermissionSeeder;
use Modules\Identity\Filament\Pages\ManageAppSettings;
use Modules\Shared\Support\EnvFileWriter;
beforeEach(function () {
$this->seed(RolePermissionSeeder::class);
// Never let a test write to the real project .env — bind the writer to
// a throwaway temp file instead.
$this->envPath = sys_get_temp_dir().'/manage-app-settings-test-'.uniqid().'.env';
file_put_contents($this->envPath, "APP_NAME=Laravel\n");
app()->instance(EnvFileWriter::class, new EnvFileWriter($this->envPath));
});
afterEach(function () {
@unlink($this->envPath);
});
test('an admin without manage_settings is forbidden from the app settings page', function () {
$support = User::factory()->create();
$support->assignRole('support');
$this->actingAs($support)->get('/admin/manage-app-settings')->assertForbidden();
});
test('a super_admin can view and save app settings, writing them to .env', function () {
$superAdmin = User::factory()->create();
$superAdmin->assignRole('super_admin');
$this->actingAs($superAdmin);
Livewire::test(ManageAppSettings::class)
->assertOk()
->fillForm([
'site_name' => 'EV Booking Co',
'support_email' => 'help@evbooking.test',
'support_phone' => '+95912345678',
'timezone' => 'Asia/Yangon',
'currency' => 'MMK',
'back_seat_enabled' => false,
'whole_vehicle_enabled' => true,
'front_seat_max_per_booking' => 2,
])
->call('save')
->assertHasNoFormErrors();
$contents = file_get_contents($this->envPath);
expect($contents)
->toContain('APP_NAME="EV Booking Co"')
->toContain('SUPPORT_EMAIL=help@evbooking.test')
->toContain('APP_TIMEZONE=Asia/Yangon')
->toContain('APP_CURRENCY=MMK')
->toContain('BOOKING_BACK_SEAT_ENABLED=false')
->toContain('BOOKING_WHOLE_VEHICLE_ENABLED=true')
->toContain('BOOKING_FRONT_SEAT_MAX_PER_BOOKING=2');
});
test('front seat max per booking must be at least 1', function () {
$superAdmin = User::factory()->create();
$superAdmin->assignRole('super_admin');
$this->actingAs($superAdmin);
Livewire::test(ManageAppSettings::class)
->fillForm(['front_seat_max_per_booking' => 0])
->call('save')
->assertHasFormErrors(['front_seat_max_per_booking']);
});
@@ -0,0 +1,36 @@
<?php
use App\Models\User;
use Modules\Identity\Database\Seeders\RolePermissionSeeder;
use Modules\Identity\Filament\Resources\Roles\RoleResource;
use Spatie\Permission\Models\Role;
beforeEach(function () {
$this->seed(RolePermissionSeeder::class);
$this->superAdmin = User::factory()->create();
$this->superAdmin->assignRole('super_admin');
});
test('a super_admin can list and edit roles', function () {
$this->actingAs($this->superAdmin)->get('/admin/roles')->assertSuccessful();
$role = Role::where('name', 'support')->firstOrFail();
$this->actingAs($this->superAdmin)->get("/admin/roles/{$role->id}/edit")->assertSuccessful();
});
test('an admin without manage_roles is forbidden from the role resource', function () {
$admin = User::factory()->create();
$admin->assignRole('admin');
$this->actingAs($admin)->get('/admin/roles')->assertForbidden();
});
test('roles cannot be created or deleted from the resource', function () {
expect(RoleResource::canCreate())->toBeFalse();
$role = Role::where('name', 'support')->firstOrFail();
expect(RoleResource::canDelete($role))->toBeFalse();
});
@@ -0,0 +1,59 @@
<?php
use App\Models\User;
use Livewire\Livewire;
use Modules\Identity\Database\Seeders\RolePermissionSeeder;
use Modules\Identity\Filament\Resources\Staff\Pages\ListStaff;
use Modules\Identity\Filament\Resources\Staff\StaffResource;
beforeEach(function () {
$this->seed(RolePermissionSeeder::class);
$this->superAdmin = User::factory()->create();
$this->superAdmin->assignRole('super_admin');
});
test('a super_admin can list, create, and edit staff', function () {
$this->actingAs($this->superAdmin)->get('/admin/staff')->assertSuccessful();
$this->actingAs($this->superAdmin)->get('/admin/staff/create')->assertSuccessful();
$other = User::factory()->create();
$other->assignRole('support');
$this->actingAs($this->superAdmin)->get("/admin/staff/{$other->id}/edit")->assertSuccessful();
});
test('an admin without manage_staff is forbidden from the staff resource', function () {
$admin = User::factory()->create();
$admin->assignRole('admin');
$this->actingAs($admin)->get('/admin/staff')->assertForbidden();
});
test('the staff resource only lists users carrying an admin-tier role', function () {
$support = User::factory()->create();
$support->assignRole('support');
$customer = User::factory()->create();
$this->actingAs($this->superAdmin);
Livewire::test(ListStaff::class)
->assertCanSeeTableRecords([$support])
->assertCanNotSeeTableRecords([$customer]);
});
test('a super_admin cannot delete their own staff account', function () {
$this->actingAs($this->superAdmin);
expect(StaffResource::canDelete($this->superAdmin))->toBeFalse();
});
test('a super_admin can delete another staff account', function () {
$other = User::factory()->create();
$other->assignRole('support');
$this->actingAs($this->superAdmin);
expect(StaffResource::canDelete($other))->toBeTrue();
});