Files
alrahma_sunday_school_api/tests/Feature/Api/V1/FullSurface/ApiFullSurfaceEndToEndContractTest.php
T
root 5e35fefd69
API CI/CD / Validate (composer + pint) (push) Successful in 2m47s
API CI/CD / Test (PHPUnit) (push) Failing after 3m8s
API CI/CD / Build frontend assets (push) Failing after 5m22s
API CI/CD / Security audit (push) Failing after 34s
API CI/CD / Deploy to shared hosting (PHP) (push) Has been skipped
fix test errors
2026-06-26 15:37:03 -04:00

381 lines
14 KiB
PHP

<?php
namespace Tests\Feature\Api\V1\FullSurface;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Routing\Route as LaravelRoute;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Route;
use Tests\Concerns\AssertsE2EApiResponses;
use Tests\Concerns\SeedsE2ETestFixtures;
use Tests\TestCase;
/**
* Full API surface executable contract probes.
*
* These tests intentionally walk the registered Laravel routes instead of only
* the endpoints humans remembered to write by hand. Humans forget things. Routes
* do not. This does not replace the named workflow scenarios; it makes the
* whole API surface prove that each endpoint has a controlled response shape,
* auth boundary, and validation boundary under realistic seeded context.
*/
class ApiFullSurfaceEndToEndContractTest extends TestCase
{
use AssertsE2EApiResponses;
use RefreshDatabase;
use SeedsE2ETestFixtures;
/**
* @var array<string, int|string>
*/
private array $ids = [];
private User $admin;
private User $teacher;
private User $parent;
private User $studentUser;
protected function setUp(): void
{
parent::setUp();
$this->seedE2EConfiguration();
$world = $this->seedTeacherClassWithStudent(9101);
$this->teacher = $world['teacher'];
$this->parent = $world['parent'];
$this->studentUser = $this->createApiUserWithRole('student');
$this->admin = $this->createApiUserWithRole('administrator');
$invoiceId = $this->seedInvoiceForParent((int) $world['parent_id']);
$voucherId = $this->seedDiscountVoucher();
$this->ids = [
'id' => 1,
'userId' => $this->admin->id,
'roleId' => (int) DB::table('roles')->where('name', 'parent')->value('id'),
'permissionId' => 1,
'student' => (int) $world['student_id'],
'studentId' => (int) $world['student_id'],
'parent' => (int) $world['parent_id'],
'parentId' => (int) $world['parent_id'],
'teacher' => $this->teacher->id,
'teacherId' => $this->teacher->id,
'classSection' => (int) $world['class_section_id'],
'classSectionId' => (int) $world['class_section_id'],
'class_section_id' => (int) $world['class_section_id'],
'invoice' => $invoiceId,
'invoiceId' => $invoiceId,
'payment' => 1,
'paymentId' => 1,
'refund' => 1,
'refundId' => 1,
'voucher' => $voucherId,
'schoolYear' => (int) DB::table('school_years')->where('name', self::E2E_SCHOOL_YEAR)->value('id'),
'promotionId' => 1,
'batchId' => 1,
'contactId' => 1,
'familyId' => 1,
'authorizedUserId' => 1,
'eventCharge' => 1,
'plan' => 1,
'carryforward' => 1,
'installment' => 1,
'certificateNumber' => 'CERT-E2E-001',
'token' => 'invalid-token-for-contract-probe',
'name' => 'placeholder.txt',
'mode' => 'view',
];
}
public function test_every_registered_api_route_has_a_controlled_e2e_contract_response(): void
{
$failures = [];
foreach ($this->probeableApiRoutes() as $route) {
$method = $this->primaryMethod($route);
$uri = '/'.$this->materializeUri($route->uri());
$actor = $this->actorFor($route->uri());
$payload = $this->payloadFor($method, $route->uri());
$this->actingAs($actor, 'api');
$response = $this->json($method, $uri, $payload);
$status = $response->getStatusCode();
if ($status >= 500) {
$failures[] = "$method $uri crashed with $status: ".$response->getContent();
continue;
}
if (! in_array($status, $this->allowedContractStatuses($method), true)) {
$failures[] = "$method $uri returned unexpected $status: ".$response->getContent();
}
}
$this->assertSame([], $failures, "Full-surface API contract failures:\n".implode("\n", $failures));
}
public function test_all_mutating_api_routes_reject_empty_or_malformed_payloads_without_server_errors(): void
{
$failures = [];
foreach ($this->probeableApiRoutes() as $route) {
$method = $this->primaryMethod($route);
if (! in_array($method, ['POST', 'PUT', 'PATCH', 'DELETE'], true)) {
continue;
}
$uri = '/'.$this->materializeUri($route->uri());
$this->actingAs($this->actorFor($route->uri()), 'api');
$response = $this->json($method, $uri, []);
$status = $response->getStatusCode();
if ($status >= 500) {
$failures[] = "$method $uri malformed payload crashed with $status: ".$response->getContent();
}
}
$this->assertSame([], $failures, "Malformed-payload probes must fail cleanly, not explode:\n".implode("\n", $failures));
}
public function test_portal_routes_enforce_wrong_actor_boundaries_across_the_full_surface(): void
{
$failures = [];
foreach ($this->portalBoundaryProbes() as [$actor, $method, $uri]) {
$this->actingAs($actor, 'api');
$response = $this->json($method, $this->materializePath($uri), $this->payloadFor($method, ltrim($uri, '/')));
$status = $response->getStatusCode();
if (! in_array($status, [401, 403, 404, 405, 419, 422], true)) {
$failures[] = "$method $uri allowed wrong actor with $status: ".$response->getContent();
}
}
$this->assertSame([], $failures, "Wrong-role portal boundary failures:\n".implode("\n", $failures));
}
/**
* @return list<LaravelRoute>
*/
private function probeableApiRoutes(): array
{
return array_values(array_filter(Route::getRoutes()->getRoutes(), function (LaravelRoute $route): bool {
$uri = $route->uri();
if (! str_starts_with($uri, 'api/')) {
return false;
}
if (str_contains($uri, 'documentation/swagger.json')) {
return false;
}
if (str_contains($uri, 'files/') || str_contains($uri, '/pdf') || str_contains($uri, '/csv')) {
return false;
}
return ! in_array($this->primaryMethod($route), ['HEAD', 'OPTIONS'], true);
}));
}
private function primaryMethod(LaravelRoute $route): string
{
foreach ($route->methods() as $method) {
if (! in_array($method, ['HEAD', 'OPTIONS'], true)) {
return $method;
}
}
return 'GET';
}
private function actorFor(string $uri): User
{
if (str_contains($uri, '/parents') || str_contains($uri, '/parent') || str_contains($uri, 'confirm_authorized_user')) {
return $this->parent;
}
if (str_contains($uri, '/teacher') || str_contains($uri, '/teachers') || str_contains($uri, 'class-progress')) {
return $this->teacher;
}
if (str_contains($uri, '/students/dashboard')) {
return $this->studentUser;
}
return $this->admin;
}
/**
* @return list<array{0: User, 1: string, 2: string}>
*/
private function portalBoundaryProbes(): array
{
return [
[$this->parent, 'GET', '/api/v1/administrator/dashboard/metrics'],
[$this->parent, 'GET', '/api/v1/administrator/enrollment-withdrawal'],
[$this->teacher, 'GET', '/api/v1/parents/profile'],
[$this->admin, 'PATCH', '/api/v1/parents/students/{studentId}'],
[$this->parent, 'GET', '/api/v1/finance/financial-report'],
[$this->parent, 'GET', '/api/v1/inventory/items'],
[$this->teacher, 'POST', '/api/v1/school-years/{schoolYear}/close'],
[$this->studentUser, 'GET', '/api/v1/teachers/classes'],
];
}
private function materializeUri(string $uri): string
{
return preg_replace_callback('/\{([^}:]+)(?:\?)?[^}]*\}/', function (array $matches): string {
$key = $matches[1];
return (string) ($this->ids[$key] ?? $this->ids['id']);
}, $uri) ?? $uri;
}
private function materializePath(string $path): string
{
return '/'.ltrim($this->materializeUri(ltrim($path, '/')), '/');
}
/**
* @return array<string, mixed>
*/
private function payloadFor(string $method, string $uri): array
{
if ($method === 'GET' || $method === 'DELETE') {
return [];
}
$base = [
'school_year' => self::E2E_SCHOOL_YEAR,
'semester' => self::E2E_SEMESTER,
'date' => '2025-10-05',
'notes' => 'E2E full-surface contract probe',
];
if (str_contains($uri, 'auth/login') || preg_match('#api/(?:v1/)?login$#', $uri)) {
return ['email' => $this->admin->email, 'password' => 'password'];
}
if (str_contains($uri, 'users')) {
return $this->validUserPayload((int) $this->ids['roleId']);
}
if (str_contains($uri, 'students')) {
return $base + [
'firstname' => 'Contract',
'lastname' => 'Student',
'dob' => '2015-01-01',
'age' => 10,
'gender' => 'Male',
'photo_consent' => true,
'is_new' => true,
'is_active' => true,
'registration_grade' => '5',
'registration_date' => '2025-09-01',
'tuition_paid' => false,
'year_of_registration' => 2025,
'parent_id' => $this->ids['parentId'],
'student_id' => $this->ids['studentId'],
'class_section_id' => [$this->ids['classSectionId']],
];
}
if (str_contains($uri, 'attendance')) {
return $base + [
'student_id' => $this->ids['studentId'],
'class_section_id' => $this->ids['classSectionId'],
'status' => 'present',
'attendance' => [$this->ids['studentId'] => 'present'],
'arrival_time' => '10:05',
];
}
if (str_contains($uri, 'scores') || str_contains($uri, 'grading')) {
return $base + [
'student_id' => $this->ids['studentId'],
'class_section_id' => $this->ids['classSectionId'],
'score' => 95,
'points' => 10,
'column' => 'Contract Probe',
'comment' => 'Solid work.',
];
}
if (str_contains($uri, 'finance') || str_contains($uri, 'invoice') || str_contains($uri, 'payment') || str_contains($uri, 'refund')) {
return $base + [
'parent_id' => $this->ids['parentId'],
'student_id' => $this->ids['studentId'],
'invoice_id' => $this->ids['invoiceId'],
'amount' => 25.00,
'payment_method' => 'cash',
'reason' => 'E2E contract probe',
];
}
if (str_contains($uri, 'inventory') || str_contains($uri, 'suppliers') || str_contains($uri, 'supply')) {
return $base + [
'name' => 'Contract Probe Item',
'sku' => 'E2E-'.uniqid(),
'quantity' => 3,
'unit_cost' => 5.25,
];
}
if (str_contains($uri, 'messages') || str_contains($uri, 'support') || str_contains($uri, 'contact') || str_contains($uri, 'email')) {
return $base + [
'subject' => 'Contract Probe',
'message' => 'This is an automated E2E contract probe.',
'body' => 'This is an automated E2E contract probe.',
'email' => 'contract.probe@example.test',
'recipient_id' => $this->teacher->id,
];
}
if (str_contains($uri, 'preferences') || str_contains($uri, 'settings') || str_contains($uri, 'configuration')) {
return $base + [
'key' => 'contract_probe',
'value' => 'enabled',
'config_key' => 'contract_probe',
'config_value' => 'enabled',
];
}
if (str_contains($uri, 'families') || str_contains($uri, 'authorized')) {
return $base + [
'parent_id' => $this->ids['parentId'],
'student_id' => $this->ids['studentId'],
'email' => 'guardian.contract@example.test',
'firstname' => 'Guardian',
'lastname' => 'Probe',
];
}
return $base + [
'name' => 'Contract Probe',
'title' => 'Contract Probe',
'description' => 'E2E full-surface contract probe',
'status' => 'active',
];
}
/**
* @return list<int>
*/
private function allowedContractStatuses(string $method): array
{
return match ($method) {
'POST' => [200, 201, 202, 204, 302, 400, 401, 403, 404, 405, 409, 419, 422, 429],
'PUT', 'PATCH' => [200, 202, 204, 302, 400, 401, 403, 404, 405, 409, 419, 422, 429],
'DELETE' => [200, 202, 204, 302, 400, 401, 403, 404, 405, 409, 419, 422, 429],
default => [200, 204, 302, 304, 400, 401, 403, 404, 405, 409, 419, 422, 429],
};
}
}