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
381 lines
14 KiB
PHP
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],
|
|
};
|
|
}
|
|
}
|