Files
alrahma_sunday_school_api/tests/Feature/Api/ApiUseCaseCoverageMatrixTest.php
T
root e0dfc3ec82
API CI/CD / Validate (composer + pint) (push) Successful in 3m15s
API CI/CD / Test (PHPUnit) (push) Failing after 5m4s
API CI/CD / Build frontend assets (push) Successful in 1m3s
API CI/CD / Security audit (push) Failing after 49s
API CI/CD / Deploy to shared hosting (PHP) (push) Has been skipped
Fixed many feature failures around preferences, route coverage, administrator enrollment, assignment section names, attendance tracking controller access, finance PDF generation, and finance notification logging.
2026-07-07 21:26:47 -04:00

170 lines
6.5 KiB
PHP

<?php
namespace Tests\Feature\Api;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Routing\Route as LaravelRoute;
use Illuminate\Support\Facades\Route;
use Tests\TestCase;
/**
* Route-to-use-case coverage guard.
*
* This does not replace workflow tests. It prevents the slower rot where a new
* endpoint is added with no explicit business workflow owner, because apparently
* endpoints reproduce when nobody is watching them.
*/
class ApiUseCaseCoverageMatrixTest extends TestCase
{
use RefreshDatabase;
public function test_every_api_route_is_mapped_to_an_explicit_use_case_domain(): void
{
$unmapped = [];
foreach ($this->apiRoutes() as $route) {
$uri = $route->uri();
if ($this->domainFor($uri) === null) {
$unmapped[] = implode('|', array_diff($route->methods(), ['HEAD'])).' '.$uri;
}
}
$this->assertSame(
[],
$unmapped,
"API routes without an explicit use-case domain:\n".implode("\n", $unmapped).
"\n\nAdd the route to the matrix and then add/extend an E2E workflow test for that domain."
);
}
public function test_required_use_case_domains_have_route_coverage(): void
{
$domains = collect($this->apiRoutes())
->map(fn (LaravelRoute $route): ?string => $this->domainFor($route->uri()))
->filter()
->unique()
->sort()
->values()
->all();
$missing = array_values(array_diff(array_keys($this->useCaseDomains()), $domains));
$this->assertSame(
[],
$missing,
"Required use-case domains with no matching API route coverage:\n".implode("\n", $missing)
);
}
public function test_every_protected_api_route_rejects_anonymous_requests(): void
{
$failures = [];
foreach ($this->protectedRoutes() as $route) {
$method = $this->primaryMethod($route);
$uri = '/'.$this->uriWithSafePlaceholders($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 protected API routes unexpectedly:\n".implode("\n", $failures)
);
}
/**
* @return array<string, string>
*/
private function useCaseDomains(): array
{
return [
'public_content_and_support' => '#^api/(?:v1/)?(?:login|register|auth|documentation|docs|docs/public|policies|pages|contact|frontend|health|system/db-check|access_denied|certificates/verify|timeoff/notify|winners/competitions|confirm_authorized_user|set_authorized_user_password|badge_scan|scanner)(?:/|$)#',
'legacy_compatibility' => '#^api/(?:proofread|emails|compare|attendance-templates|attendance-comment-templates|administrator/attendance-templates)(?:/|$)#',
'preferences_navigation_and_ui' => '#^api/v1/(?:preferences|nav-builder|landing|info-icon|dashboard|utilities|ui|settings|configuration|configuration-admin|system|stats|role-switcher)(?:/|$)#',
'administration_enrollment_and_school_years' => '#^api/v1/(?:admin|administrator|school-years|class-sections|users|role-permissions|ip-bans|notifications)(?:/|$)#',
'students_parents_and_families' => '#^api/v1/(?:students|parents|parent-profiles|families|family-admin|emergency-contacts)(?:/|$)#',
'teachers_staff_and_class_operations' => '#^api/v1/(?:teacher|teachers|staff|assignments|class-prep|class-progress)(?:/|$)#',
'attendance_and_safety' => '#^api/v1/(?:attendance|attendance-tracking|attendance-comment-templates|attendance-templates|incidents)(?:/|$)#',
'grading_scores_and_reporting' => '#^api/v1/(?:scores|grading|reports|badges|certificates|print-requests|subjects/curriculum|exams/drafts)(?:/|$)#',
'finance_billing_and_inventory' => '#^api/v1/(?:finance|discounts|expenses|extra-charges|inventory|files)(?:/|$)#',
'communications_and_messaging' => '#^api/v1/(?:messages|broadcast-email|email|email-extractor|communications|support|whatsapp)(?:/|$)#',
'competition_and_public_results' => '#^api/v1/(?:competition-scores|competition-winners)(?:/|$)#',
];
}
private function domainFor(string $uri): ?string
{
foreach ($this->useCaseDomains() as $domain => $pattern) {
if (preg_match($pattern, $uri) === 1) {
return $domain;
}
}
return null;
}
/**
* @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/');
}));
}
/**
* @return list<LaravelRoute>
*/
private function protectedRoutes(): array
{
return array_values(array_filter($this->apiRoutes(), function (LaravelRoute $route): bool {
if ($this->isStreamOrGeneratedFileRoute($route->uri())) {
return false;
}
return collect($route->gatherMiddleware())->contains(function (string $middleware): bool {
return str_contains($middleware, 'auth:api')
|| str_contains($middleware, 'jwt.auth')
|| str_contains($middleware, 'admin.access')
|| str_contains($middleware, 'parent.access')
|| str_contains($middleware, 'teacher.portal.auth');
});
}));
}
private function isStreamOrGeneratedFileRoute(string $uri): bool
{
return str_contains($uri, 'files/')
|| str_contains($uri, 'receipts/')
|| str_contains($uri, 'reimbursements/')
|| str_contains($uri, 'attachments/')
|| str_contains($uri, '/pdf')
|| str_contains($uri, '/csv');
}
private function primaryMethod(LaravelRoute $route): string
{
foreach ($route->methods() as $method) {
if (! in_array($method, ['HEAD', 'OPTIONS'], true)) {
return $method;
}
}
return 'GET';
}
private function uriWithSafePlaceholders(string $uri): string
{
return preg_replace('/\{[^}]+\}/', '1', $uri) ?? $uri;
}
}