add api tests
This commit is contained in:
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
@@ -0,0 +1,87 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Api;
|
||||
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\Concerns\CreatesApiTestUsers;
|
||||
use Tests\TestCase;
|
||||
|
||||
class ApiAttendanceTemplateFeatureTest extends TestCase
|
||||
{
|
||||
use CreatesApiTestUsers;
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_attendance_comment_templates_can_be_managed_through_current_api(): void
|
||||
{
|
||||
$this->actingAsApiAdministrator();
|
||||
|
||||
$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');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Api;
|
||||
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Routing\Route as LaravelRoute;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Tests\Concerns\CreatesApiTestUsers;
|
||||
use Tests\TestCase;
|
||||
|
||||
class ApiAuthenticationAndAuthorizationTest extends TestCase
|
||||
{
|
||||
use CreatesApiTestUsers;
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_auth_me_requires_authentication(): void
|
||||
{
|
||||
$this->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<array{0: string, 1: string}>
|
||||
*/
|
||||
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<LaravelRoute>
|
||||
*/
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Api;
|
||||
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\Concerns\CreatesApiTestUsers;
|
||||
use Tests\TestCase;
|
||||
|
||||
class ApiPreferencesFeatureTest extends TestCase
|
||||
{
|
||||
use CreatesApiTestUsers;
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_authenticated_user_can_create_read_update_and_delete_preferences(): void
|
||||
{
|
||||
$user = $this->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']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Api;
|
||||
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class ApiPublicEndpointsTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_health_endpoint_is_available(): void
|
||||
{
|
||||
$this->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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Api;
|
||||
|
||||
use App\Models\Permission;
|
||||
use App\Models\Role;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\Concerns\CreatesApiTestUsers;
|
||||
use Tests\TestCase;
|
||||
|
||||
class ApiRolePermissionFeatureTest extends TestCase
|
||||
{
|
||||
use CreatesApiTestUsers;
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_admin_can_manage_roles_permissions_and_assign_roles(): void
|
||||
{
|
||||
$this->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']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Api;
|
||||
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Routing\Route as LaravelRoute;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Tests\TestCase;
|
||||
|
||||
class ApiRouteContractTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_every_api_route_points_to_an_existing_controller_method(): void
|
||||
{
|
||||
$missing = [];
|
||||
|
||||
foreach ($this->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<LaravelRoute>
|
||||
*/
|
||||
private function apiRoutes(): array
|
||||
{
|
||||
return array_values(array_filter(Route::getRoutes()->getRoutes(), function (LaravelRoute $route): bool {
|
||||
return str_starts_with($route->uri(), 'api/');
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Api;
|
||||
|
||||
use App\Models\Role;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Tests\Concerns\CreatesApiTestUsers;
|
||||
use Tests\TestCase;
|
||||
|
||||
class ApiUserManagementFeatureTest extends TestCase
|
||||
{
|
||||
use CreatesApiTestUsers;
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_admin_can_create_list_update_and_delete_a_user_through_api(): void
|
||||
{
|
||||
$this->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']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Api\V1\PrintRequests;
|
||||
|
||||
use App\Models\PrintRequest;
|
||||
use App\Models\Role;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Tests\TestCase;
|
||||
|
||||
class PrintRequestsWorkflowTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_teacher_can_create_hand_copy_request_and_admin_can_advance_statuses(): void
|
||||
{
|
||||
$teacher = $this->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<string, mixed> $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));
|
||||
}
|
||||
}
|
||||
@@ -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([
|
||||
|
||||
Reference in New Issue
Block a user