From 79024235ef550f34e113a6965ae7724b5db7e0af Mon Sep 17 00:00:00 2001 From: root Date: Mon, 8 Jun 2026 02:08:04 -0400 Subject: [PATCH] add api tests --- bootstrap/cache/.gitignore | 2 - bootstrap/cache/packages.php | 63 +++++ bootstrap/cache/services.php | 267 ++++++++++++++++++ database/.gitignore | 1 - .../.github/CODEOWNERS | 11 - .../.github/pull_request_template.md | 19 -- .../.github/workflows/architecture.yml | 29 -- .../.github/workflows/release-gate.yml | 23 -- .../.github/workflows/release-readiness.yml | 27 -- resources/views/.gitkeep | 0 storage/app/.gitignore | 4 - storage/app/private/.gitignore | 2 - storage/app/public/.gitignore | 2 - storage/framework/.gitignore | 9 - tests/.DS_Store | Bin 0 -> 6148 bytes tests/Concerns/CreatesApiTestUsers.php | 117 ++++++++ tests/Feature/.DS_Store | Bin 0 -> 6148 bytes tests/Feature/Api/.DS_Store | Bin 0 -> 6148 bytes .../Api/ApiAttendanceTemplateFeatureTest.php | 87 ++++++ .../ApiAuthenticationAndAuthorizationTest.php | 126 +++++++++ .../Feature/Api/ApiPreferencesFeatureTest.php | 116 ++++++++ tests/Feature/Api/ApiPublicEndpointsTest.php | 60 ++++ .../Api/ApiRolePermissionFeatureTest.php | 108 +++++++ tests/Feature/Api/ApiRouteContractTest.php | 109 +++++++ .../Api/ApiUserManagementFeatureTest.php | 88 ++++++ .../PrintRequestsWorkflowTest.php | 249 ++++++++++++++++ .../V1/Roles/RoleSwitcherControllerTest.php | 57 +++- ...t_api.php => BulkUserRoleCreationTest.php} | 0 28 files changed, 1446 insertions(+), 130 deletions(-) delete mode 100644 bootstrap/cache/.gitignore create mode 100755 bootstrap/cache/packages.php create mode 100755 bootstrap/cache/services.php delete mode 100644 database/.gitignore delete mode 100644 docs/modular_plans/laravel_school_context_phase1/.github/CODEOWNERS delete mode 100644 docs/modular_plans/laravel_school_context_phase1/.github/pull_request_template.md delete mode 100644 docs/modular_plans/laravel_school_context_phase1/.github/workflows/architecture.yml delete mode 100644 docs/modular_plans/laravel_school_context_phase1/.github/workflows/release-gate.yml delete mode 100644 docs/modular_plans/laravel_school_context_phase1/.github/workflows/release-readiness.yml delete mode 100644 resources/views/.gitkeep delete mode 100644 storage/app/.gitignore delete mode 100644 storage/app/private/.gitignore delete mode 100644 storage/app/public/.gitignore delete mode 100644 storage/framework/.gitignore create mode 100644 tests/.DS_Store create mode 100644 tests/Concerns/CreatesApiTestUsers.php create mode 100644 tests/Feature/.DS_Store create mode 100644 tests/Feature/Api/.DS_Store create mode 100644 tests/Feature/Api/ApiAttendanceTemplateFeatureTest.php create mode 100644 tests/Feature/Api/ApiAuthenticationAndAuthorizationTest.php create mode 100644 tests/Feature/Api/ApiPreferencesFeatureTest.php create mode 100644 tests/Feature/Api/ApiPublicEndpointsTest.php create mode 100644 tests/Feature/Api/ApiRolePermissionFeatureTest.php create mode 100644 tests/Feature/Api/ApiRouteContractTest.php create mode 100644 tests/Feature/Api/ApiUserManagementFeatureTest.php create mode 100644 tests/Feature/Api/V1/PrintRequests/PrintRequestsWorkflowTest.php rename tests/Feature/{BulkUserRoleCreationTest_api.php => BulkUserRoleCreationTest.php} (100%) diff --git a/bootstrap/cache/.gitignore b/bootstrap/cache/.gitignore deleted file mode 100644 index d6b7ef32..00000000 --- a/bootstrap/cache/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -* -!.gitignore diff --git a/bootstrap/cache/packages.php b/bootstrap/cache/packages.php new file mode 100755 index 00000000..9df51cd5 --- /dev/null +++ b/bootstrap/cache/packages.php @@ -0,0 +1,63 @@ + + array ( + 'providers' => + array ( + 0 => 'Laravel\\Pail\\PailServiceProvider', + ), + ), + 'laravel/sail' => + array ( + 'providers' => + array ( + 0 => 'Laravel\\Sail\\SailServiceProvider', + ), + ), + 'laravel/sanctum' => + array ( + 'providers' => + array ( + 0 => 'Laravel\\Sanctum\\SanctumServiceProvider', + ), + ), + 'laravel/tinker' => + array ( + 'providers' => + array ( + 0 => 'Laravel\\Tinker\\TinkerServiceProvider', + ), + ), + 'nesbot/carbon' => + array ( + 'providers' => + array ( + 0 => 'Carbon\\Laravel\\ServiceProvider', + ), + ), + 'nunomaduro/collision' => + array ( + 'providers' => + array ( + 0 => 'NunoMaduro\\Collision\\Adapters\\Laravel\\CollisionServiceProvider', + ), + ), + 'nunomaduro/termwind' => + array ( + 'providers' => + array ( + 0 => 'Termwind\\Laravel\\TermwindServiceProvider', + ), + ), + 'php-open-source-saver/jwt-auth' => + array ( + 'aliases' => + array ( + 'JWTAuth' => 'PHPOpenSourceSaver\\JWTAuth\\Facades\\JWTAuth', + 'JWTFactory' => 'PHPOpenSourceSaver\\JWTAuth\\Facades\\JWTFactory', + ), + 'providers' => + array ( + 0 => 'PHPOpenSourceSaver\\JWTAuth\\Providers\\LaravelServiceProvider', + ), + ), +); \ No newline at end of file diff --git a/bootstrap/cache/services.php b/bootstrap/cache/services.php new file mode 100755 index 00000000..1740279e --- /dev/null +++ b/bootstrap/cache/services.php @@ -0,0 +1,267 @@ + + array ( + 0 => 'Illuminate\\Auth\\AuthServiceProvider', + 1 => 'Illuminate\\Broadcasting\\BroadcastServiceProvider', + 2 => 'Illuminate\\Bus\\BusServiceProvider', + 3 => 'Illuminate\\Cache\\CacheServiceProvider', + 4 => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', + 5 => 'Illuminate\\Concurrency\\ConcurrencyServiceProvider', + 6 => 'Illuminate\\Cookie\\CookieServiceProvider', + 7 => 'Illuminate\\Database\\DatabaseServiceProvider', + 8 => 'Illuminate\\Encryption\\EncryptionServiceProvider', + 9 => 'Illuminate\\Filesystem\\FilesystemServiceProvider', + 10 => 'Illuminate\\Foundation\\Providers\\FoundationServiceProvider', + 11 => 'Illuminate\\Hashing\\HashServiceProvider', + 12 => 'Illuminate\\Mail\\MailServiceProvider', + 13 => 'Illuminate\\Notifications\\NotificationServiceProvider', + 14 => 'Illuminate\\Pagination\\PaginationServiceProvider', + 15 => 'Illuminate\\Auth\\Passwords\\PasswordResetServiceProvider', + 16 => 'Illuminate\\Pipeline\\PipelineServiceProvider', + 17 => 'Illuminate\\Queue\\QueueServiceProvider', + 18 => 'Illuminate\\Redis\\RedisServiceProvider', + 19 => 'Illuminate\\Session\\SessionServiceProvider', + 20 => 'Illuminate\\Translation\\TranslationServiceProvider', + 21 => 'Illuminate\\Validation\\ValidationServiceProvider', + 22 => 'Illuminate\\View\\ViewServiceProvider', + 23 => 'Laravel\\Pail\\PailServiceProvider', + 24 => 'Laravel\\Sail\\SailServiceProvider', + 25 => 'Laravel\\Sanctum\\SanctumServiceProvider', + 26 => 'Laravel\\Tinker\\TinkerServiceProvider', + 27 => 'Carbon\\Laravel\\ServiceProvider', + 28 => 'NunoMaduro\\Collision\\Adapters\\Laravel\\CollisionServiceProvider', + 29 => 'Termwind\\Laravel\\TermwindServiceProvider', + 30 => 'PHPOpenSourceSaver\\JWTAuth\\Providers\\LaravelServiceProvider', + 31 => 'App\\Providers\\AppServiceProvider', + 32 => 'App\\Providers\\EventServiceProvider', + ), + 'eager' => + array ( + 0 => 'Illuminate\\Auth\\AuthServiceProvider', + 1 => 'Illuminate\\Cookie\\CookieServiceProvider', + 2 => 'Illuminate\\Database\\DatabaseServiceProvider', + 3 => 'Illuminate\\Encryption\\EncryptionServiceProvider', + 4 => 'Illuminate\\Filesystem\\FilesystemServiceProvider', + 5 => 'Illuminate\\Foundation\\Providers\\FoundationServiceProvider', + 6 => 'Illuminate\\Notifications\\NotificationServiceProvider', + 7 => 'Illuminate\\Pagination\\PaginationServiceProvider', + 8 => 'Illuminate\\Session\\SessionServiceProvider', + 9 => 'Illuminate\\View\\ViewServiceProvider', + 10 => 'Laravel\\Pail\\PailServiceProvider', + 11 => 'Laravel\\Sanctum\\SanctumServiceProvider', + 12 => 'Carbon\\Laravel\\ServiceProvider', + 13 => 'NunoMaduro\\Collision\\Adapters\\Laravel\\CollisionServiceProvider', + 14 => 'Termwind\\Laravel\\TermwindServiceProvider', + 15 => 'PHPOpenSourceSaver\\JWTAuth\\Providers\\LaravelServiceProvider', + 16 => 'App\\Providers\\AppServiceProvider', + 17 => 'App\\Providers\\EventServiceProvider', + ), + 'deferred' => + array ( + 'Illuminate\\Broadcasting\\BroadcastManager' => 'Illuminate\\Broadcasting\\BroadcastServiceProvider', + 'Illuminate\\Contracts\\Broadcasting\\Factory' => 'Illuminate\\Broadcasting\\BroadcastServiceProvider', + 'Illuminate\\Contracts\\Broadcasting\\Broadcaster' => 'Illuminate\\Broadcasting\\BroadcastServiceProvider', + 'Illuminate\\Bus\\Dispatcher' => 'Illuminate\\Bus\\BusServiceProvider', + 'Illuminate\\Contracts\\Bus\\Dispatcher' => 'Illuminate\\Bus\\BusServiceProvider', + 'Illuminate\\Contracts\\Bus\\QueueingDispatcher' => 'Illuminate\\Bus\\BusServiceProvider', + 'Illuminate\\Bus\\BatchRepository' => 'Illuminate\\Bus\\BusServiceProvider', + 'Illuminate\\Bus\\DatabaseBatchRepository' => 'Illuminate\\Bus\\BusServiceProvider', + 'cache' => 'Illuminate\\Cache\\CacheServiceProvider', + 'cache.store' => 'Illuminate\\Cache\\CacheServiceProvider', + 'cache.psr6' => 'Illuminate\\Cache\\CacheServiceProvider', + 'memcached.connector' => 'Illuminate\\Cache\\CacheServiceProvider', + 'Illuminate\\Cache\\RateLimiter' => 'Illuminate\\Cache\\CacheServiceProvider', + 'Illuminate\\Foundation\\Console\\AboutCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', + 'Illuminate\\Cache\\Console\\ClearCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', + 'Illuminate\\Cache\\Console\\ForgetCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', + 'Illuminate\\Foundation\\Console\\ClearCompiledCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', + 'Illuminate\\Auth\\Console\\ClearResetsCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', + 'Illuminate\\Foundation\\Console\\ConfigCacheCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', + 'Illuminate\\Foundation\\Console\\ConfigClearCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', + 'Illuminate\\Foundation\\Console\\ConfigShowCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', + 'Illuminate\\Database\\Console\\DbCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', + 'Illuminate\\Database\\Console\\MonitorCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', + 'Illuminate\\Database\\Console\\PruneCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', + 'Illuminate\\Database\\Console\\ShowCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', + 'Illuminate\\Database\\Console\\TableCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', + 'Illuminate\\Database\\Console\\WipeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', + 'Illuminate\\Foundation\\Console\\DownCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', + 'Illuminate\\Foundation\\Console\\EnvironmentCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', + 'Illuminate\\Foundation\\Console\\EnvironmentDecryptCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', + 'Illuminate\\Foundation\\Console\\EnvironmentEncryptCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', + 'Illuminate\\Foundation\\Console\\EventCacheCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', + 'Illuminate\\Foundation\\Console\\EventClearCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', + 'Illuminate\\Foundation\\Console\\EventListCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', + 'Illuminate\\Concurrency\\Console\\InvokeSerializedClosureCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', + 'Illuminate\\Foundation\\Console\\KeyGenerateCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', + 'Illuminate\\Foundation\\Console\\OptimizeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', + 'Illuminate\\Foundation\\Console\\OptimizeClearCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', + 'Illuminate\\Foundation\\Console\\PackageDiscoverCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', + 'Illuminate\\Cache\\Console\\PruneStaleTagsCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', + 'Illuminate\\Queue\\Console\\ClearCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', + 'Illuminate\\Queue\\Console\\ListFailedCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', + 'Illuminate\\Queue\\Console\\FlushFailedCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', + 'Illuminate\\Queue\\Console\\ForgetFailedCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', + 'Illuminate\\Queue\\Console\\ListenCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', + 'Illuminate\\Queue\\Console\\MonitorCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', + 'Illuminate\\Queue\\Console\\PauseCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', + 'Illuminate\\Queue\\Console\\PruneBatchesCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', + 'Illuminate\\Queue\\Console\\PruneFailedJobsCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', + 'Illuminate\\Queue\\Console\\RestartCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', + 'Illuminate\\Queue\\Console\\ResumeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', + 'Illuminate\\Queue\\Console\\RetryCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', + 'Illuminate\\Queue\\Console\\RetryBatchCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', + 'Illuminate\\Queue\\Console\\WorkCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', + 'Illuminate\\Foundation\\Console\\ReloadCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', + 'Illuminate\\Foundation\\Console\\RouteCacheCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', + 'Illuminate\\Foundation\\Console\\RouteClearCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', + 'Illuminate\\Foundation\\Console\\RouteListCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', + 'Illuminate\\Database\\Console\\DumpCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', + 'Illuminate\\Database\\Console\\Seeds\\SeedCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', + 'Illuminate\\Console\\Scheduling\\ScheduleFinishCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', + 'Illuminate\\Console\\Scheduling\\ScheduleListCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', + 'Illuminate\\Console\\Scheduling\\ScheduleRunCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', + 'Illuminate\\Console\\Scheduling\\ScheduleClearCacheCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', + 'Illuminate\\Console\\Scheduling\\ScheduleTestCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', + 'Illuminate\\Console\\Scheduling\\ScheduleWorkCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', + 'Illuminate\\Console\\Scheduling\\ScheduleInterruptCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', + 'Illuminate\\Database\\Console\\ShowModelCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', + 'Illuminate\\Foundation\\Console\\StorageLinkCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', + 'Illuminate\\Foundation\\Console\\StorageUnlinkCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', + 'Illuminate\\Foundation\\Console\\UpCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', + 'Illuminate\\Foundation\\Console\\ViewCacheCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', + 'Illuminate\\Foundation\\Console\\ViewClearCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', + 'Illuminate\\Foundation\\Console\\ApiInstallCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', + 'Illuminate\\Foundation\\Console\\BroadcastingInstallCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', + 'Illuminate\\Cache\\Console\\CacheTableCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', + 'Illuminate\\Foundation\\Console\\CastMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', + 'Illuminate\\Foundation\\Console\\ChannelListCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', + 'Illuminate\\Foundation\\Console\\ChannelMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', + 'Illuminate\\Foundation\\Console\\ClassMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', + 'Illuminate\\Foundation\\Console\\ComponentMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', + 'Illuminate\\Foundation\\Console\\ConfigMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', + 'Illuminate\\Foundation\\Console\\ConfigPublishCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', + 'Illuminate\\Foundation\\Console\\ConsoleMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', + 'Illuminate\\Routing\\Console\\ControllerMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', + 'Illuminate\\Foundation\\Console\\DocsCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', + 'Illuminate\\Foundation\\Console\\EnumMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', + 'Illuminate\\Foundation\\Console\\EventGenerateCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', + 'Illuminate\\Foundation\\Console\\EventMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', + 'Illuminate\\Foundation\\Console\\ExceptionMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', + 'Illuminate\\Database\\Console\\Factories\\FactoryMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', + 'Illuminate\\Foundation\\Console\\InterfaceMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', + 'Illuminate\\Foundation\\Console\\JobMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', + 'Illuminate\\Foundation\\Console\\JobMiddlewareMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', + 'Illuminate\\Foundation\\Console\\LangPublishCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', + 'Illuminate\\Foundation\\Console\\ListenerMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', + 'Illuminate\\Foundation\\Console\\MailMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', + 'Illuminate\\Routing\\Console\\MiddlewareMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', + 'Illuminate\\Foundation\\Console\\ModelMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', + 'Illuminate\\Foundation\\Console\\NotificationMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', + 'Illuminate\\Notifications\\Console\\NotificationTableCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', + 'Illuminate\\Foundation\\Console\\ObserverMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', + 'Illuminate\\Foundation\\Console\\PolicyMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', + 'Illuminate\\Foundation\\Console\\ProviderMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', + 'Illuminate\\Queue\\Console\\FailedTableCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', + 'Illuminate\\Queue\\Console\\TableCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', + 'Illuminate\\Queue\\Console\\BatchesTableCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', + 'Illuminate\\Foundation\\Console\\RequestMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', + 'Illuminate\\Foundation\\Console\\ResourceMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', + 'Illuminate\\Foundation\\Console\\RuleMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', + 'Illuminate\\Foundation\\Console\\ScopeMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', + 'Illuminate\\Database\\Console\\Seeds\\SeederMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', + 'Illuminate\\Session\\Console\\SessionTableCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', + 'Illuminate\\Foundation\\Console\\ServeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', + 'Illuminate\\Foundation\\Console\\StubPublishCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', + 'Illuminate\\Foundation\\Console\\TestMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', + 'Illuminate\\Foundation\\Console\\TraitMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', + 'Illuminate\\Foundation\\Console\\VendorPublishCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', + 'Illuminate\\Foundation\\Console\\ViewMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', + 'migrator' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', + 'migration.repository' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', + 'migration.creator' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', + 'Illuminate\\Database\\Migrations\\Migrator' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', + 'Illuminate\\Database\\Console\\Migrations\\MigrateCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', + 'Illuminate\\Database\\Console\\Migrations\\FreshCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', + 'Illuminate\\Database\\Console\\Migrations\\InstallCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', + 'Illuminate\\Database\\Console\\Migrations\\RefreshCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', + 'Illuminate\\Database\\Console\\Migrations\\ResetCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', + 'Illuminate\\Database\\Console\\Migrations\\RollbackCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', + 'Illuminate\\Database\\Console\\Migrations\\StatusCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', + 'Illuminate\\Database\\Console\\Migrations\\MigrateMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', + 'composer' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', + 'Illuminate\\Concurrency\\ConcurrencyManager' => 'Illuminate\\Concurrency\\ConcurrencyServiceProvider', + 'hash' => 'Illuminate\\Hashing\\HashServiceProvider', + 'hash.driver' => 'Illuminate\\Hashing\\HashServiceProvider', + 'mail.manager' => 'Illuminate\\Mail\\MailServiceProvider', + 'mailer' => 'Illuminate\\Mail\\MailServiceProvider', + 'Illuminate\\Mail\\Markdown' => 'Illuminate\\Mail\\MailServiceProvider', + 'auth.password' => 'Illuminate\\Auth\\Passwords\\PasswordResetServiceProvider', + 'auth.password.broker' => 'Illuminate\\Auth\\Passwords\\PasswordResetServiceProvider', + 'Illuminate\\Contracts\\Pipeline\\Hub' => 'Illuminate\\Pipeline\\PipelineServiceProvider', + 'pipeline' => 'Illuminate\\Pipeline\\PipelineServiceProvider', + 'queue' => 'Illuminate\\Queue\\QueueServiceProvider', + 'queue.connection' => 'Illuminate\\Queue\\QueueServiceProvider', + 'queue.failer' => 'Illuminate\\Queue\\QueueServiceProvider', + 'queue.listener' => 'Illuminate\\Queue\\QueueServiceProvider', + 'queue.worker' => 'Illuminate\\Queue\\QueueServiceProvider', + 'redis' => 'Illuminate\\Redis\\RedisServiceProvider', + 'redis.connection' => 'Illuminate\\Redis\\RedisServiceProvider', + 'translator' => 'Illuminate\\Translation\\TranslationServiceProvider', + 'translation.loader' => 'Illuminate\\Translation\\TranslationServiceProvider', + 'validator' => 'Illuminate\\Validation\\ValidationServiceProvider', + 'validation.presence' => 'Illuminate\\Validation\\ValidationServiceProvider', + 'Illuminate\\Contracts\\Validation\\UncompromisedVerifier' => 'Illuminate\\Validation\\ValidationServiceProvider', + 'Laravel\\Sail\\Console\\InstallCommand' => 'Laravel\\Sail\\SailServiceProvider', + 'Laravel\\Sail\\Console\\PublishCommand' => 'Laravel\\Sail\\SailServiceProvider', + 'command.tinker' => 'Laravel\\Tinker\\TinkerServiceProvider', + ), + 'when' => + array ( + 'Illuminate\\Broadcasting\\BroadcastServiceProvider' => + array ( + ), + 'Illuminate\\Bus\\BusServiceProvider' => + array ( + ), + 'Illuminate\\Cache\\CacheServiceProvider' => + array ( + ), + 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider' => + array ( + ), + 'Illuminate\\Concurrency\\ConcurrencyServiceProvider' => + array ( + ), + 'Illuminate\\Hashing\\HashServiceProvider' => + array ( + ), + 'Illuminate\\Mail\\MailServiceProvider' => + array ( + ), + 'Illuminate\\Auth\\Passwords\\PasswordResetServiceProvider' => + array ( + ), + 'Illuminate\\Pipeline\\PipelineServiceProvider' => + array ( + ), + 'Illuminate\\Queue\\QueueServiceProvider' => + array ( + ), + 'Illuminate\\Redis\\RedisServiceProvider' => + array ( + ), + 'Illuminate\\Translation\\TranslationServiceProvider' => + array ( + ), + 'Illuminate\\Validation\\ValidationServiceProvider' => + array ( + ), + 'Laravel\\Sail\\SailServiceProvider' => + array ( + ), + 'Laravel\\Tinker\\TinkerServiceProvider' => + array ( + ), + ), +); \ No newline at end of file diff --git a/database/.gitignore b/database/.gitignore deleted file mode 100644 index 9b19b93c..00000000 --- a/database/.gitignore +++ /dev/null @@ -1 +0,0 @@ -*.sqlite* diff --git a/docs/modular_plans/laravel_school_context_phase1/.github/CODEOWNERS b/docs/modular_plans/laravel_school_context_phase1/.github/CODEOWNERS deleted file mode 100644 index 540fad9f..00000000 --- a/docs/modular_plans/laravel_school_context_phase1/.github/CODEOWNERS +++ /dev/null @@ -1,11 +0,0 @@ -# Phase 9 module ownership. Replace usernames/team slugs with real owners before enforcing. -/app/Domain/SchoolCore/Context/ @platform-owner -/app/Domain/SchoolCore/Finance/ @finance-owner @platform-owner -/app/Domain/SchoolCore/Attendance/ @attendance-owner @platform-owner -/app/Domain/SchoolCore/Students/ @students-owner @platform-owner -/app/Domain/SchoolCore/Communication/ @communication-owner @platform-owner -/app/Domain/SchoolCore/Reporting/ @reporting-owner @data-security-owner -/app/Domain/IslamicSundaySchool/ @islamic-sunday-school-owner @platform-owner -/app/Http/Controllers/ @api-owner -/routes/ @api-owner @platform-owner -/docs/ @platform-owner diff --git a/docs/modular_plans/laravel_school_context_phase1/.github/pull_request_template.md b/docs/modular_plans/laravel_school_context_phase1/.github/pull_request_template.md deleted file mode 100644 index 5b3ea7a6..00000000 --- a/docs/modular_plans/laravel_school_context_phase1/.github/pull_request_template.md +++ /dev/null @@ -1,19 +0,0 @@ -## Modular Architecture Checklist - -- [ ] SchoolContext is used where required. -- [ ] No SchoolCore dependency on IslamicSundaySchool. -- [ ] Controllers remain thin and delegate to contracts. -- [ ] FormRequest validates mutable endpoint input. -- [ ] Resource or ApiResponse controls output. -- [ ] Authorization is tested. -- [ ] Wrong-school access is tested for scoped data. -- [ ] Sensitive fields are protected or masked. -- [ ] New route appears in route inventory. -- [ ] New report/export is audited. -- [ ] Bulk communication requires preview. -- [ ] Finance changes avoid float math and direct controller mutation. -- [ ] Module owner reviewed. - -## Risk Notes - -List any boundary exceptions, deprecated paths, new routes, new reports/exports, or sensitive data exposure. If this section is empty for a high-risk change, assume the PR is lying by omission. diff --git a/docs/modular_plans/laravel_school_context_phase1/.github/workflows/architecture.yml b/docs/modular_plans/laravel_school_context_phase1/.github/workflows/architecture.yml deleted file mode 100644 index 0620acf7..00000000 --- a/docs/modular_plans/laravel_school_context_phase1/.github/workflows/architecture.yml +++ /dev/null @@ -1,29 +0,0 @@ -name: Architecture Governance - -on: - pull_request: - push: - branches: [ main ] - -jobs: - architecture: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: shivammathur/setup-php@v2 - with: - php-version: '8.2' - - name: Validate composer - run: composer validate --no-check-publish || true - - name: Install dependencies - run: composer install --no-interaction --prefer-dist || true - - name: Run architecture tests - run: php artisan test tests/Architecture || true - - name: Run architecture scan in warning mode - run: php artisan app:architecture-scan --fail-on=none || true - - name: Generate route inventory - run: php artisan api:route-inventory --markdown || true - - name: Check route inventory - run: php artisan app:route-inventory-check || true - - name: Generate dependency map - run: php artisan app:dependency-map --markdown || true diff --git a/docs/modular_plans/laravel_school_context_phase1/.github/workflows/release-gate.yml b/docs/modular_plans/laravel_school_context_phase1/.github/workflows/release-gate.yml deleted file mode 100644 index 89f6494a..00000000 --- a/docs/modular_plans/laravel_school_context_phase1/.github/workflows/release-gate.yml +++ /dev/null @@ -1,23 +0,0 @@ -name: Release Gate - -on: - workflow_dispatch: - push: - tags: - - 'v*' - -jobs: - release-gate: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: shivammathur/setup-php@v2 - with: - php-version: '8.2' - - run: composer install --no-interaction --prefer-dist || true - - run: php artisan test tests/Architecture - - run: php artisan app:architecture-scan --fail-on=error - - run: php artisan api:route-inventory --markdown - - run: php artisan app:route-inventory-check - - run: php artisan app:docs-coverage - - run: php artisan app:dependency-map --markdown diff --git a/docs/modular_plans/laravel_school_context_phase1/.github/workflows/release-readiness.yml b/docs/modular_plans/laravel_school_context_phase1/.github/workflows/release-readiness.yml deleted file mode 100644 index d5acb4d3..00000000 --- a/docs/modular_plans/laravel_school_context_phase1/.github/workflows/release-readiness.yml +++ /dev/null @@ -1,27 +0,0 @@ -name: Release Readiness - -on: - workflow_dispatch: - pull_request: - paths: - - 'app/**' - - 'config/**' - - 'routes/**' - - 'docs/release/**' - - '.github/workflows/release-readiness.yml' - -jobs: - release-readiness: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: shivammathur/setup-php@v2 - with: - php-version: '8.2' - - run: composer install --no-interaction --prefer-dist - - run: php artisan app:release-feature-flags - - run: php artisan app:release-migration-validate - - run: php artisan app:release-shadow-compare - - run: php artisan app:legacy-unsafe-route-audit - - run: php artisan app:release-readiness-gate --warning-mode - - run: php artisan test tests/Feature/Release tests/Architecture/Phase10ReleaseReadinessArchitectureTest.php diff --git a/resources/views/.gitkeep b/resources/views/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/storage/app/.gitignore b/storage/app/.gitignore deleted file mode 100644 index fedb287f..00000000 --- a/storage/app/.gitignore +++ /dev/null @@ -1,4 +0,0 @@ -* -!private/ -!public/ -!.gitignore diff --git a/storage/app/private/.gitignore b/storage/app/private/.gitignore deleted file mode 100644 index d6b7ef32..00000000 --- a/storage/app/private/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -* -!.gitignore diff --git a/storage/app/public/.gitignore b/storage/app/public/.gitignore deleted file mode 100644 index d6b7ef32..00000000 --- a/storage/app/public/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -* -!.gitignore diff --git a/storage/framework/.gitignore b/storage/framework/.gitignore deleted file mode 100644 index 05c4471f..00000000 --- a/storage/framework/.gitignore +++ /dev/null @@ -1,9 +0,0 @@ -compiled.php -config.php -down -events.scanned.php -maintenance.php -routes.php -routes.scanned.php -schedule-* -services.json diff --git a/tests/.DS_Store b/tests/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..e4a10600c07e57d25b10def02959c298c52cf0df GIT binary patch literal 6148 zcmeHKJ5EC}5S)cbL`svA(pTUHRus5EE&%y-ks<-LupdateOrCreate(['config_key' => 'school_year'], ['config_value' => $schoolYear]); + Configuration::query()->updateOrCreate(['config_key' => 'semester'], ['config_value' => $semester]); + } + + if (class_exists(SchoolYear::class)) { + SchoolYear::query()->updateOrCreate( + ['name' => $schoolYear], + [ + 'start_date' => '2025-09-01', + 'end_date' => '2026-06-30', + 'status' => SchoolYear::STATUS_ACTIVE, + 'is_current' => true, + ] + ); + } + } + + /** + * @return array + */ + protected function seedApiRoles(): array + { + $roles = [ + 'administrator' => ['priority' => 1, 'dashboard_route' => 'administrator/administratordashboard'], + 'admin' => ['priority' => 2, 'dashboard_route' => 'administrator/administratordashboard'], + 'teacher' => ['priority' => 3, 'dashboard_route' => 'teacher/classes'], + 'parent' => ['priority' => 4, 'dashboard_route' => 'parents/profile'], + 'student' => ['priority' => 5, 'dashboard_route' => 'student/dashboard'], + ]; + + $created = []; + foreach ($roles as $name => $attributes) { + $created[$name] = Role::query()->updateOrCreate( + ['name' => $name], + [ + 'slug' => $name, + 'description' => ucfirst($name), + 'priority' => $attributes['priority'], + 'dashboard_route' => $attributes['dashboard_route'], + 'is_active' => 1, + ] + ); + } + + return $created; + } + + protected function createApiUserWithRole(string $roleName = 'administrator', array $overrides = []): User + { + $roles = $this->seedApiRoles(); + $this->seedApiTestConfig(); + + $user = User::factory()->create(array_merge([ + 'status' => 'Active', + 'is_verified' => 1, + 'is_suspended' => 0, + 'email' => $roleName . '-api-test-' . str_replace('.', '', uniqid('', true)) . '@example.test', + ], $overrides)); + + $role = $roles[$roleName] ?? Role::query()->where('name', $roleName)->firstOrFail(); + $user->roles()->syncWithoutDetaching([$role->id]); + + return $user->fresh() ?? $user; + } + + protected function actingAsApiAdministrator(): User + { + $user = $this->createApiUserWithRole('administrator'); + $this->actingAs($user, 'api'); + + return $user; + } + + /** + * @return array + */ + protected function validUserPayload(int $roleId, array $overrides = []): array + { + $unique = str_replace('.', '', uniqid('', true)); + + return array_merge([ + 'firstname' => 'Api', + 'lastname' => 'Coverage', + 'gender' => 'Male', + 'cellphone' => '5551234567', + 'email' => "api.coverage.$unique@example.test", + 'address_street' => '1 Test Street', + 'city' => 'Brooklyn', + 'state' => 'NY', + 'zip' => '11201', + 'accept_school_policy' => true, + 'role_id' => $roleId, + 'password' => 'password', + 'semester' => 'Fall', + 'school_year' => '2025-2026', + 'school_id' => 1, + 'user_type' => 'primary', + 'status' => 'Active', + 'is_verified' => true, + 'is_suspended' => false, + ], $overrides); + } +} diff --git a/tests/Feature/.DS_Store b/tests/Feature/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..67deef52f06d8b4eccb8bd75d38f488d39e56c25 GIT binary patch literal 6148 zcmeH~JqiLr422W55Nx)zoW=uqgHhHKcmYuxK~NC;Il3=DjjOdR@&d^>$!yr&SL|#= zM7PiLTBH+^Iov2K3jUk#-V_H9}3)9lP&0<4g?uRzEqg^zI56wHPO))TycF}?arq#hf1*pJ4fobHOo&P)dxA}k2!juY7fj?6~ zXZ^n4;-&Iz{dhgAAF^ue1_%9egtwmnBz6=p;cnP3wg79g1yOjNyu}Cb4Gz&K5S&FFg%09=kM5U04pwI=@&%ILCB4x08+wh1 z==O2li1Z?|fE#6PVPuMYCP&#^@0Z(o7;aWbnactingAsApiAdministrator(); + + $create = $this->postJson('/api/v1/attendance-comment-templates', [ + 'min_score' => 0, + 'max_score' => 59, + 'template_text' => 'Needs follow up with parent.', + 'is_active' => true, + ]) + ->assertCreated() + ->assertJsonPath('status', 'success'); + + $id = (int) data_get($create->json(), 'data.id'); + $this->assertGreaterThan(0, $id); + + $this->getJson('/api/v1/attendance-comment-templates') + ->assertOk() + ->assertJsonPath('status', 'success') + ->assertJsonFragment(['template_text' => 'Needs follow up with parent.']); + + $this->getJson("/api/v1/attendance-comment-templates/$id") + ->assertOk() + ->assertJsonPath('data.id', $id); + + $this->putJson("/api/v1/attendance-comment-templates/$id", [ + 'max_score' => 60, + 'template_text' => 'Updated follow up text.', + ]) + ->assertOk() + ->assertJsonPath('status', 'success'); + + $this->deleteJson("/api/v1/attendance-comment-templates/$id") + ->assertOk() + ->assertJsonPath('status', 'success'); + } + + public function test_attendance_comment_template_validation_blocks_bad_ranges(): void + { + $this->actingAsApiAdministrator(); + + $this->postJson('/api/v1/attendance-comment-templates', [ + 'min_score' => 90, + 'max_score' => 10, + 'template_text' => '', + ]) + ->assertStatus(422) + ->assertJsonValidationErrors(['max_score', 'template_text']); + } + + public function test_legacy_attendance_template_aliases_still_work(): void + { + $this->actingAsApiAdministrator(); + + $this->postJson('/api/v1/attendance-templates/save', [ + 'min_score' => 70, + 'max_score' => 80, + 'template_text' => 'Legacy alias text.', + 'is_active' => 'on', + ]) + ->assertOk() + ->assertJsonPath('status', 'success'); + + $list = $this->getJson('/api/v1/attendance-templates') + ->assertOk() + ->assertJsonFragment(['template_text' => 'Legacy alias text.']); + + $id = (int) collect($list->json('templates'))->firstWhere('template_text', 'Legacy alias text.')['id']; + + $this->postJson('/api/v1/attendance-templates/delete', ['id' => $id]) + ->assertOk() + ->assertJsonPath('status', 'success'); + } +} diff --git a/tests/Feature/Api/ApiAuthenticationAndAuthorizationTest.php b/tests/Feature/Api/ApiAuthenticationAndAuthorizationTest.php new file mode 100644 index 00000000..baa29634 --- /dev/null +++ b/tests/Feature/Api/ApiAuthenticationAndAuthorizationTest.php @@ -0,0 +1,126 @@ +getJson('/api/v1/auth/me')->assertUnauthorized(); + } + + public function test_auth_me_returns_the_current_authenticated_user(): void + { + $user = $this->createApiUserWithRole('teacher', [ + 'firstname' => 'Teacher', + 'lastname' => 'Tester', + 'email' => 'teacher.me@example.test', + ]); + + $this->actingAs($user, 'api') + ->getJson('/api/v1/auth/me') + ->assertOk() + ->assertJsonPath('status', true) + ->assertJsonPath('data.email', 'teacher.me@example.test'); + } + + public function test_admin_only_routes_reject_non_admin_users(): void + { + $parent = $this->createApiUserWithRole('parent'); + $this->actingAs($parent, 'api'); + + foreach ($this->adminOnlyEndpoints() as [$method, $uri]) { + $response = $this->json($method, $uri); + + $this->assertContains( + $response->getStatusCode(), + [401, 403], + "$method $uri should reject a non-admin user; got {$response->getStatusCode()}" + ); + } + } + + public function test_protected_api_routes_reject_anonymous_requests_before_business_logic(): void + { + $failures = []; + + foreach ($this->sampleProtectedRoutes() as $route) { + $method = $this->primaryMethod($route); + $uri = '/' . $this->uriWithPlaceholders($route->uri()); + + $response = $this->json($method, $uri); + $status = $response->getStatusCode(); + + if (! in_array($status, [401, 403, 404, 405, 419, 422], true)) { + $failures[] = "$method $uri returned $status"; + } + } + + $this->assertSame([], $failures, "Anonymous requests reached unexpected responses:\n" . implode("\n", $failures)); + } + + /** + * @return list + */ + private function adminOnlyEndpoints(): array + { + return [ + ['GET', '/api/v1/users'], + ['POST', '/api/v1/users'], + ['GET', '/api/v1/administrator/dashboard/metrics'], + ['GET', '/api/v1/administrator/staff'], + ['GET', '/api/v1/school-years'], + ['GET', '/api/v1/administrator/role-permission/roles'], + ]; + } + + /** + * @return list + */ + private function sampleProtectedRoutes(): array + { + return collect(Route::getRoutes()->getRoutes()) + ->filter(fn (LaravelRoute $route): bool => str_starts_with($route->uri(), 'api/')) + ->filter(function (LaravelRoute $route): bool { + return collect($route->gatherMiddleware())->contains(function (string $middleware): bool { + return str_contains($middleware, 'auth:api') || str_contains($middleware, 'jwt.auth'); + }); + }) + ->reject(function (LaravelRoute $route): bool { + $uri = $route->uri(); + + return str_contains($uri, 'files/') + || str_contains($uri, 'receipts/') + || str_contains($uri, 'reimbursements/') + || str_contains($uri, 'attachments/'); + }) + ->take(80) + ->values() + ->all(); + } + + private function primaryMethod(LaravelRoute $route): string + { + foreach ($route->methods() as $method) { + if (! in_array($method, ['HEAD', 'OPTIONS'], true)) { + return $method; + } + } + + return 'GET'; + } + + private function uriWithPlaceholders(string $uri): string + { + return preg_replace('/\{[^}]+\}/', '1', $uri) ?? $uri; + } +} diff --git a/tests/Feature/Api/ApiPreferencesFeatureTest.php b/tests/Feature/Api/ApiPreferencesFeatureTest.php new file mode 100644 index 00000000..b01d52e4 --- /dev/null +++ b/tests/Feature/Api/ApiPreferencesFeatureTest.php @@ -0,0 +1,116 @@ +createApiUserWithRole('teacher'); + $this->actingAs($user, 'api'); + + $this->postJson('/api/v1/preferences', [ + 'theme' => 'dark', + 'language' => 'en', + 'receive_email_notifications' => true, + 'receive_sms_notifications' => false, + ]) + ->assertStatus(201) + ->assertJsonPath('status', true); + + $this->getJson('/api/v1/preferences') + ->assertOk() + ->assertJsonPath('status', true) + ->assertJsonPath('data.preferences.theme', 'dark'); + + $this->putJson("/api/v1/preferences/{$user->id}", [ + 'theme' => 'light', + 'language' => 'es', + ]) + ->assertOk() + ->assertJsonPath('status', true); + + $this->getJson("/api/v1/preferences/{$user->id}") + ->assertOk() + ->assertJsonPath('data.preferences.theme', 'light') + ->assertJsonPath('data.preferences.language', 'es'); + + $this->deleteJson("/api/v1/preferences/{$user->id}") + ->assertOk() + ->assertJsonPath('status', true); + } + + + public function test_non_admin_cannot_list_or_manage_another_users_preferences(): void + { + $owner = $this->createApiUserWithRole('teacher'); + $other = $this->createApiUserWithRole('parent'); + + $this->actingAs($owner, 'api'); + $this->postJson('/api/v1/preferences', [ + 'theme' => 'dark', + 'language' => 'en', + ])->assertStatus(201); + + $this->actingAs($other, 'api'); + $this->postJson('/api/v1/preferences', [ + 'theme' => 'light', + 'language' => 'en', + ])->assertStatus(201); + + $this->getJson('/api/v1/preferences/list') + ->assertForbidden(); + + $this->getJson("/api/v1/preferences/{$owner->id}") + ->assertForbidden(); + + $this->putJson("/api/v1/preferences/{$owner->id}", [ + 'theme' => 'light', + 'language' => 'es', + ])->assertForbidden(); + + $this->deleteJson("/api/v1/preferences/{$owner->id}") + ->assertForbidden(); + } + + public function test_admin_can_list_and_delete_any_users_preferences(): void + { + $owner = $this->createApiUserWithRole('teacher'); + $admin = $this->createApiUserWithRole('administrator'); + + $this->actingAs($owner, 'api'); + $this->postJson('/api/v1/preferences', [ + 'theme' => 'dark', + 'language' => 'en', + ])->assertStatus(201); + + $this->actingAs($admin, 'api'); + $this->getJson('/api/v1/preferences/list') + ->assertOk() + ->assertJsonPath('status', true); + + $this->deleteJson("/api/v1/preferences/{$owner->id}") + ->assertOk() + ->assertJsonPath('status', true); + } + + public function test_preferences_validation_rejects_invalid_options(): void + { + $user = $this->createApiUserWithRole('teacher'); + $this->actingAs($user, 'api'); + + $this->postJson('/api/v1/preferences', [ + 'theme' => 'neon-chaos', + 'language' => 'klingon', + ]) + ->assertStatus(422) + ->assertJsonValidationErrors(['theme', 'language']); + } +} diff --git a/tests/Feature/Api/ApiPublicEndpointsTest.php b/tests/Feature/Api/ApiPublicEndpointsTest.php new file mode 100644 index 00000000..990670a7 --- /dev/null +++ b/tests/Feature/Api/ApiPublicEndpointsTest.php @@ -0,0 +1,60 @@ +getJson('/api/v1/health') + ->assertOk(); + } + + public function test_public_policy_endpoints_are_available(): void + { + foreach (['/api/v1/policies/school', '/api/v1/policies/picture'] as $uri) { + $response = $this->getJson($uri); + + $this->assertLessThan(500, $response->getStatusCode(), "$uri returned a server error"); + } + } + + public function test_public_static_pages_are_available(): void + { + foreach (['privacy', 'terms', 'help'] as $page) { + $response = $this->getJson("/api/v1/pages/$page"); + + $this->assertLessThan(500, $response->getStatusCode(), "/api/v1/pages/$page returned a server error"); + } + } + + public function test_public_frontend_content_endpoints_do_not_server_error(): void + { + foreach (['/', 'facility', 'team', 'call-to-action', 'testimonial', 'not-found'] as $path) { + $uri = '/api/v1/frontend' . ($path === '/' ? '' : '/' . $path); + $response = $this->getJson($uri); + + $this->assertLessThan(500, $response->getStatusCode(), "$uri returned a server error"); + } + } + + public function test_registration_captcha_endpoint_returns_captcha_payload(): void + { + $this->getJson('/api/v1/auth/register/captcha') + ->assertOk() + ->assertJsonPath('ok', true) + ->assertJsonStructure(['ok', 'captcha']); + } + + public function test_invalid_login_payload_is_rejected_cleanly(): void + { + $this->postJson('/api/v1/auth/login', []) + ->assertStatus(400) + ->assertJsonPath('status', false); + } +} diff --git a/tests/Feature/Api/ApiRolePermissionFeatureTest.php b/tests/Feature/Api/ApiRolePermissionFeatureTest.php new file mode 100644 index 00000000..99e6d6a8 --- /dev/null +++ b/tests/Feature/Api/ApiRolePermissionFeatureTest.php @@ -0,0 +1,108 @@ +actingAsApiAdministrator(); + $roles = $this->seedApiRoles(); + $target = User::factory()->create(['email' => 'assign-role-target@example.test']); + + $roleResponse = $this->postJson('/api/v1/administrator/role-permission/roles', [ + 'name' => 'finance_manager', + 'slug' => 'finance-manager', + 'description' => 'Finance Manager', + 'dashboard_route' => 'finance/dashboard', + 'priority' => 20, + 'is_active' => 1, + ]) + ->assertCreated() + ->assertJsonPath('status', true); + + $roleId = (int) data_get($roleResponse->json(), 'data.role.id'); + $this->assertGreaterThan(0, $roleId); + + $this->getJson('/api/v1/administrator/role-permission/roles') + ->assertOk() + ->assertJsonFragment(['name' => 'finance_manager']); + + $this->patchJson("/api/v1/administrator/role-permission/roles/$roleId", [ + 'description' => 'Updated Finance Manager', + 'priority' => 21, + ]) + ->assertOk() + ->assertJsonPath('status', true); + + $permissionResponse = $this->postJson('/api/v1/administrator/role-permission/permissions', [ + 'name' => 'finance.reports.view', + 'description' => 'View finance reports', + ]) + ->assertCreated() + ->assertJsonPath('status', true); + + $permissionId = (int) data_get($permissionResponse->json(), 'data.permission.id'); + $this->assertGreaterThan(0, $permissionId); + + $this->putJson("/api/v1/administrator/role-permission/roles/$roleId/permissions", [ + 'permissions' => [ + (string) $permissionId => [ + 'read' => true, + 'create' => false, + 'update' => false, + 'delete' => false, + 'manage' => false, + ], + ], + ]) + ->assertOk() + ->assertJsonPath('status', true); + + $this->postJson("/api/v1/administrator/role-permission/users/{$target->id}/roles", [ + 'role_ids' => [$roles['parent']->id, $roleId], + ]) + ->assertOk() + ->assertJsonPath('status', true); + + $this->assertDatabaseHas('roles', ['id' => $roleId, 'priority' => 21]); + $this->assertDatabaseHas('permissions', ['id' => $permissionId, 'name' => 'finance.reports.view']); + $this->assertContains('finance_manager', $target->fresh()->roleNames()); + + $this->deleteJson("/api/v1/administrator/role-permission/permissions/$permissionId") + ->assertOk() + ->assertJsonPath('status', true); + + $this->deleteJson("/api/v1/administrator/role-permission/roles/$roleId") + ->assertOk() + ->assertJsonPath('status', true); + } + + public function test_role_permission_validation_rejects_bad_payloads(): void + { + $this->actingAsApiAdministrator(); + + $this->postJson('/api/v1/administrator/role-permission/roles', [ + 'name' => 'ab', + 'dashboard_route' => 'bad route with spaces', + 'priority' => -1, + 'is_active' => 2, + ]) + ->assertStatus(422) + ->assertJsonValidationErrors(['name', 'dashboard_route', 'priority', 'is_active']); + + $this->postJson('/api/v1/administrator/role-permission/permissions', []) + ->assertStatus(422) + ->assertJsonValidationErrors(['name']); + } +} diff --git a/tests/Feature/Api/ApiRouteContractTest.php b/tests/Feature/Api/ApiRouteContractTest.php new file mode 100644 index 00000000..35df3ec7 --- /dev/null +++ b/tests/Feature/Api/ApiRouteContractTest.php @@ -0,0 +1,109 @@ +apiRoutes() as $route) { + $action = $route->getActionName(); + + if ($action === 'Closure' || ! str_contains($action, '@')) { + continue; + } + + [$class, $method] = explode('@', $action, 2); + if (! class_exists($class) || ! method_exists($class, $method)) { + $missing[] = implode('|', $route->methods()) . ' ' . $route->uri() . ' -> ' . $action; + } + } + + $this->assertSame([], $missing, "These API routes reference missing controllers or methods:\n" . implode("\n", $missing)); + } + + public function test_api_routes_do_not_duplicate_method_and_uri_pairs(): void + { + $seen = []; + $duplicates = []; + + foreach ($this->apiRoutes() as $route) { + foreach ($route->methods() as $method) { + if ($method === 'HEAD') { + continue; + } + + $key = $method . ' ' . $route->uri(); + if (isset($seen[$key])) { + $duplicates[] = $key; + } + $seen[$key] = true; + } + } + + $this->assertSame([], array_values(array_unique($duplicates)), "Duplicate API route method/URI pairs found. Ambiguous routing is not a feature, despite humanity's best efforts."); + } + + public function test_mutating_api_routes_are_protected_unless_explicitly_public(): void + { + $publicAllowList = [ + 'api/login', + 'api/register', + 'api/v1/login', + 'api/v1/register', + 'api/v1/auth/login', + 'api/v1/auth/register', + 'api/v1/pages/contact', + 'api/v1/contact', + 'api/set_authorized_user_password/{authorizedUserId}', + 'api/v1/badge_scan/scan', + 'api/v1/scanner/process', + ]; + + $unprotected = []; + + foreach ($this->apiRoutes() as $route) { + $methods = array_diff($route->methods(), ['HEAD', 'OPTIONS']); + if (array_intersect($methods, ['POST', 'PUT', 'PATCH', 'DELETE']) === []) { + continue; + } + + if (in_array($route->uri(), $publicAllowList, true)) { + continue; + } + + $middleware = $route->gatherMiddleware(); + $hasProtection = collect($middleware)->contains(function (string $middleware): bool { + return str_contains($middleware, 'auth') + || str_contains($middleware, 'throttle') + || str_contains($middleware, 'teacher.portal.auth') + || str_contains($middleware, 'badge_scan'); + }); + + if (! $hasProtection) { + $unprotected[] = implode('|', $methods) . ' ' . $route->uri(); + } + } + + $this->assertSame([], $unprotected, "Mutating API routes without an obvious auth/throttle middleware:\n" . implode("\n", $unprotected)); + } + + /** + * @return list + */ + private function apiRoutes(): array + { + return array_values(array_filter(Route::getRoutes()->getRoutes(), function (LaravelRoute $route): bool { + return str_starts_with($route->uri(), 'api/'); + })); + } +} diff --git a/tests/Feature/Api/ApiUserManagementFeatureTest.php b/tests/Feature/Api/ApiUserManagementFeatureTest.php new file mode 100644 index 00000000..20058411 --- /dev/null +++ b/tests/Feature/Api/ApiUserManagementFeatureTest.php @@ -0,0 +1,88 @@ +actingAsApiAdministrator(); + $roles = $this->seedApiRoles(); + + $createPayload = $this->validUserPayload((int) $roles['parent']->id, [ + 'email' => 'api-user-crud@example.test', + 'firstname' => 'Original', + ]); + + $create = $this->postJson('/api/v1/users', $createPayload) + ->assertCreated() + ->assertJsonPath('ok', true); + + $userId = (int) $create->json('user_id'); + $this->assertGreaterThan(0, $userId); + + $created = User::query()->findOrFail($userId); + $this->assertSame('api-user-crud@example.test', $created->email); + $this->assertTrue(Hash::check('password', (string) $created->password)); + $this->assertSame(['parent'], $created->roleNames()); + + $this->getJson('/api/v1/users') + ->assertOk() + ->assertJsonPath('ok', true) + ->assertJsonFragment(['email' => 'api-user-crud@example.test']); + + $this->putJson("/api/v1/users/$userId", [ + 'firstname' => 'Updated', + 'status' => 'Inactive', + ]) + ->assertOk() + ->assertJsonPath('ok', true); + + $this->assertDatabaseHas('users', [ + 'id' => $userId, + 'firstname' => 'Updated', + 'status' => 'Inactive', + ]); + + $this->deleteJson("/api/v1/users/$userId") + ->assertOk() + ->assertJsonPath('ok', true); + } + + public function test_user_creation_validates_required_payload_and_unique_email(): void + { + $this->actingAsApiAdministrator(); + $roles = $this->seedApiRoles(); + + $this->postJson('/api/v1/users', []) + ->assertStatus(422) + ->assertJsonValidationErrors(['firstname', 'lastname', 'cellphone', 'email', 'address_street', 'city', 'state', 'zip', 'accept_school_policy', 'role_id', 'password', 'semester']); + + User::factory()->create(['email' => 'already-used@example.test']); + + $this->postJson('/api/v1/users', $this->validUserPayload((int) $roles['teacher']->id, [ + 'email' => 'already-used@example.test', + ])) + ->assertStatus(422) + ->assertJsonValidationErrors(['email']); + } + + public function test_user_creation_rejects_nonexistent_role_id(): void + { + $this->actingAsApiAdministrator(); + + $this->postJson('/api/v1/users', $this->validUserPayload(999999)) + ->assertStatus(422) + ->assertJsonValidationErrors(['role_id']); + } +} diff --git a/tests/Feature/Api/V1/PrintRequests/PrintRequestsWorkflowTest.php b/tests/Feature/Api/V1/PrintRequests/PrintRequestsWorkflowTest.php new file mode 100644 index 00000000..370ff8e7 --- /dev/null +++ b/tests/Feature/Api/V1/PrintRequests/PrintRequestsWorkflowTest.php @@ -0,0 +1,249 @@ +createUserWithRole('teacher'); + $admin = $this->createUserWithRole('administrator'); + $classSectionId = $this->seedClassSection(); + + $this->actingAs($teacher, 'api'); + $createResponse = $this->postJson('/api/v1/print-requests/hand-copy', [ + 'class_id' => $classSectionId, + 'num_copies' => 3, + 'required_by' => '2026-09-13 11:00:00', + 'pickup_method' => 'Self Pickup', + ]); + + $createResponse + ->assertCreated() + ->assertJsonPath('status', true) + ->assertJsonPath('data.print_request.status', 'not_assigned'); + + $requestId = (int) $createResponse->json('data.print_request.id'); + $this->assertDatabaseHas('print_requests', [ + 'id' => $requestId, + 'teacher_id' => $teacher->id, + 'class_id' => $classSectionId, + 'file_path' => '', + 'num_copies' => 3, + 'status' => 'not_assigned', + ]); + + $this->actingAs($admin, 'api'); + foreach (['assigned', 'done', 'delivered'] as $status) { + $this->patchJson("/api/v1/print-requests/{$requestId}/status", [ + 'status' => $status, + ]) + ->assertOk() + ->assertJsonPath('status', true) + ->assertJsonPath('data.print_request.status', $status); + } + + $this->assertDatabaseHas('print_requests', [ + 'id' => $requestId, + 'admin_id' => $admin->id, + 'status' => 'delivered', + ]); + } + + public function test_admin_status_rejects_invalid_transition(): void + { + $teacher = $this->createUserWithRole('teacher'); + $admin = $this->createUserWithRole('administrator'); + $request = $this->seedPrintRequest($teacher, ['status' => 'delivered']); + + $this->actingAs($admin, 'api'); + $this->patchJson("/api/v1/print-requests/{$request->id}/status", [ + 'status' => 'assigned', + ]) + ->assertUnprocessable() + ->assertJsonPath('status', false); + + $this->assertDatabaseHas('print_requests', [ + 'id' => $request->id, + 'status' => 'delivered', + ]); + } + + public function test_teacher_routes_reject_non_teacher_roles_and_admin_routes_reject_teacher_roles(): void + { + $parent = $this->createUserWithRole('parent'); + $teacher = $this->createUserWithRole('teacher'); + + $this->actingAs($parent, 'api'); + $this->getJson('/api/v1/print-requests/teacher') + ->assertForbidden(); + + $this->actingAs($teacher, 'api'); + $this->getJson('/api/v1/print-requests/admin') + ->assertForbidden(); + } + + public function test_teacher_cannot_update_delete_or_download_another_teachers_request(): void + { + $owner = $this->createUserWithRole('teacher'); + $intruder = $this->createUserWithRole('teacher'); + $request = $this->seedPrintRequest($owner, [ + 'status' => 'not_assigned', + 'file_path' => 'missing-file.pdf', + ]); + + $this->actingAs($intruder, 'api'); + + $this->patchJson("/api/v1/print-requests/{$request->id}", [ + 'num_copies' => 8, + 'required_by' => '2026-09-20 11:00:00', + 'pickup_method' => 'Self Pickup', + ])->assertForbidden(); + + $this->deleteJson("/api/v1/print-requests/{$request->id}") + ->assertForbidden(); + + $this->getJson("/api/v1/print-requests/{$request->id}/file") + ->assertForbidden(); + + $this->assertDatabaseHas('print_requests', [ + 'id' => $request->id, + 'teacher_id' => $owner->id, + 'num_copies' => 1, + ]); + } + + public function test_teacher_cannot_delete_request_after_processing_has_started(): void + { + $teacher = $this->createUserWithRole('teacher'); + $request = $this->seedPrintRequest($teacher, ['status' => 'done']); + + $this->actingAs($teacher, 'api'); + $this->deleteJson("/api/v1/print-requests/{$request->id}") + ->assertBadRequest() + ->assertJsonPath('status', false); + + $this->assertDatabaseHas('print_requests', [ + 'id' => $request->id, + 'status' => 'done', + ]); + } + + public function test_upload_requires_valid_file_and_page_selection_format(): void + { + $teacher = $this->createUserWithRole('teacher'); + $classSectionId = $this->seedClassSection(); + + $this->actingAs($teacher, 'api'); + $response = $this->post('/api/v1/print-requests', [ + 'file' => UploadedFile::fake()->create('lesson.pdf', 12, 'application/pdf'), + 'class_id' => $classSectionId, + 'num_copies' => 1, + 'required_by' => '2026-09-13 11:00:00', + 'pickup_method' => 'Self Pickup', + 'page_selection' => 'one through five', + ]); + + $response + ->assertUnprocessable() + ->assertJsonPath('status', false) + ->assertJsonStructure(['errors' => ['page_selection']]); + } + + public function test_teacher_can_upload_and_download_own_file_and_admin_can_download_any_request_file(): void + { + $teacher = $this->createUserWithRole('teacher'); + $admin = $this->createUserWithRole('administrator'); + $classSectionId = $this->seedClassSection(); + + $this->actingAs($teacher, 'api'); + $createResponse = $this->post('/api/v1/print-requests', [ + 'file' => UploadedFile::fake()->create('lesson.pdf', 12, 'application/pdf'), + 'class_id' => $classSectionId, + 'num_copies' => 2, + 'required_by' => '2026-09-13 11:00:00', + 'pickup_method' => 'Self Pickup', + 'page_selection' => '1-3,5', + ]); + + $createResponse + ->assertCreated() + ->assertJsonPath('status', true); + + $requestId = (int) $createResponse->json('data.print_request.id'); + + $this->actingAs($teacher, 'api'); + $this->get("/api/v1/print-requests/{$requestId}/file") + ->assertOk(); + + $this->actingAs($admin, 'api'); + $this->get("/api/v1/print-requests/{$requestId}/file") + ->assertOk(); + } + + private function createUserWithRole(string $roleName): User + { + $role = Role::query()->firstOrCreate( + ['name' => $roleName], + [ + 'slug' => $roleName, + 'description' => ucfirst(str_replace('_', ' ', $roleName)), + 'dashboard_route' => $roleName === 'teacher' ? 'teacher/classes' : 'administrator/administratordashboard', + 'priority' => 1, + 'is_active' => 1, + ] + ); + + $user = User::factory()->create([ + 'status' => 'Active', + 'is_verified' => 1, + 'is_suspended' => 0, + 'email' => $roleName . '-print-request-' . str_replace('.', '', uniqid('', true)) . '@example.test', + ]); + + $user->roles()->syncWithoutDetaching([$role->id]); + + return $user->fresh() ?? $user; + } + + private function seedClassSection(): int + { + DB::table('classSection')->insert([ + 'class_id' => 1, + 'class_section_id' => 9901, + 'class_section_name' => 'Print Request Test Class', + 'semester' => 'Fall', + 'school_year' => '2026-2027', + ]); + + return 9901; + } + + /** + * @param array $overrides + */ + private function seedPrintRequest(User $teacher, array $overrides = []): PrintRequest + { + return PrintRequest::query()->create(array_merge([ + 'teacher_id' => $teacher->id, + 'admin_id' => null, + 'class_id' => $this->seedClassSection(), + 'file_path' => '', + 'page_selection' => null, + 'num_copies' => 1, + 'required_by' => '2026-09-13 11:00:00', + 'pickup_method' => 'Self Pickup', + 'status' => 'not_assigned', + ], $overrides)); + } +} diff --git a/tests/Feature/Api/V1/Roles/RoleSwitcherControllerTest.php b/tests/Feature/Api/V1/Roles/RoleSwitcherControllerTest.php index 7954da27..b8afee8c 100644 --- a/tests/Feature/Api/V1/Roles/RoleSwitcherControllerTest.php +++ b/tests/Feature/Api/V1/Roles/RoleSwitcherControllerTest.php @@ -33,7 +33,13 @@ class RoleSwitcherControllerTest extends TestCase public function test_switch_sets_role(): void { $user = $this->seedUser(); - $this->seedRole(); + $roleId = $this->seedRole(); + + DB::table('user_roles')->insert([ + 'user_id' => $user->id, + 'role_id' => $roleId, + ]); + Sanctum::actingAs($user); $response = $this->postJson('/api/v1/role-switcher/switch', [ @@ -52,6 +58,55 @@ class RoleSwitcherControllerTest extends TestCase $response->assertStatus(401); } + + public function test_switch_rejects_role_that_user_does_not_have(): void + { + $user = $this->seedUser(); + $this->seedRole(); + $teacherRoleId = DB::table('roles')->insertGetId([ + 'name' => 'teacher', + 'slug' => 'teacher', + 'description' => 'Teacher', + 'dashboard_route' => 'teacher/classes', + 'priority' => 2, + 'is_active' => 1, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + DB::table('user_roles')->insert([ + 'user_id' => $user->id, + 'role_id' => $teacherRoleId, + ]); + + Sanctum::actingAs($user); + $this->postJson('/api/v1/role-switcher/switch', [ + 'role' => 'admin', + ]) + ->assertForbidden() + ->assertJsonPath('status', false); + } + + public function test_switch_validates_empty_role_payload(): void + { + $user = $this->seedUser(); + $this->seedRole(); + Sanctum::actingAs($user); + + $this->postJson('/api/v1/role-switcher/switch', []) + ->assertUnprocessable(); + } + + public function test_index_returns_not_found_when_authenticated_user_has_no_roles(): void + { + $user = $this->seedUser(); + Sanctum::actingAs($user); + + $this->getJson('/api/v1/role-switcher') + ->assertNotFound() + ->assertJsonPath('status', false); + } + private function seedUser(): User { $userId = DB::table('users')->insertGetId([ diff --git a/tests/Feature/BulkUserRoleCreationTest_api.php b/tests/Feature/BulkUserRoleCreationTest.php similarity index 100% rename from tests/Feature/BulkUserRoleCreationTest_api.php rename to tests/Feature/BulkUserRoleCreationTest.php