add test batches

This commit is contained in:
root
2026-06-08 23:45:55 -04:00
parent c792b8be05
commit 8d4d610b82
1480 changed files with 22587 additions and 10762 deletions
@@ -54,11 +54,11 @@ class ScenarioAFamilyEnrollmentTest extends TestCase
// Teacher sees student in roster.
$this->actingAs($teacher, 'api');
$this->getJson('/api/v1/teachers/classes?class_section_id='.$classSectionId)
$this->getJson('/api/v1/teachers/classes?class_section_id=' . $classSectionId)
->assertOk()
->assertJsonPath('ok', true);
$studentIds = array_column($this->getJson('/api/v1/teachers/classes?class_section_id='.$classSectionId)->json('students'), 'student_id');
$studentIds = array_column($this->getJson('/api/v1/teachers/classes?class_section_id=' . $classSectionId)->json('students'), 'student_id');
$this->assertContains($studentId, $studentIds);
// Parent sees own profile.
@@ -89,7 +89,7 @@ class ScenarioAFamilyEnrollmentTest extends TestCase
]);
$this->actingAs($parentA, 'api');
$this->patchJson('/api/v1/parents/students/'.$studentB, [
$this->patchJson('/api/v1/parents/students/' . $studentB, [
'firstname' => 'Hacked',
'lastname' => 'Child',
'dob' => '2015-01-01',
@@ -27,7 +27,7 @@ class ScenarioBSundaySchoolDayTest extends TestCase
$this->actingAs($teacher, 'api');
$form = $this->getJson('/api/v1/attendance/teacher/form?class_section_id='.$classSectionId)
$form = $this->getJson('/api/v1/attendance/teacher/form?class_section_id=' . $classSectionId)
->assertOk();
$date = (string) ($form->json('data.current_sunday') ?? '2025-10-05');
@@ -52,7 +52,7 @@ class ScenarioBSundaySchoolDayTest extends TestCase
// Parent views attendance for their child.
$this->actingAs($parent, 'api');
$this->getJson('/api/v1/parents/attendance?school_year='.self::E2E_SCHOOL_YEAR)
$this->getJson('/api/v1/parents/attendance?school_year=' . self::E2E_SCHOOL_YEAR)
->assertOk()
->assertJsonPath('ok', true);
}
@@ -51,7 +51,7 @@ class ScenarioCAcademicTermTest extends TestCase
$this->actingAs($teacher, 'api');
$this->getJson('/api/v1/scores/overview?class_section_id='.$classSectionId.'&semester='.self::E2E_SEMESTER.'&school_year='.self::E2E_SCHOOL_YEAR)
$this->getJson('/api/v1/scores/overview?class_section_id=' . $classSectionId . '&semester=' . self::E2E_SEMESTER . '&school_year=' . self::E2E_SCHOOL_YEAR)
->assertOk()
->assertJsonPath('ok', true);
@@ -69,7 +69,7 @@ class ScenarioCAcademicTermTest extends TestCase
'is_locked' => 1,
]);
$this->getJson('/api/v1/reports/report-cards/students/'.$studentId.'?school_year='.self::E2E_SCHOOL_YEAR.'&semester='.self::E2E_SEMESTER)
$this->getJson('/api/v1/reports/report-cards/students/' . $studentId . '?school_year=' . self::E2E_SCHOOL_YEAR . '&semester=' . self::E2E_SEMESTER)
->assertOk();
}
@@ -61,7 +61,7 @@ class ScenarioDFamilyBillingTest extends TestCase
->value('id');
$this->assertGreaterThan(0, $chargeId);
$this->postJson('/api/v1/finance/event-charges/'.$chargeId.'/approve')
$this->postJson('/api/v1/finance/event-charges/' . $chargeId . '/approve')
->assertOk()
->assertJsonPath('ok', true);
@@ -81,7 +81,7 @@ class ScenarioDFamilyBillingTest extends TestCase
'invoice_id' => $invoiceId,
]);
$this->getJson('/api/v1/finance/payments/parent/'.$parentId.'?school_year='.self::E2E_SCHOOL_YEAR)
$this->getJson('/api/v1/finance/payments/parent/' . $parentId . '?school_year=' . self::E2E_SCHOOL_YEAR)
->assertOk()
->assertJsonPath('ok', true);
@@ -26,7 +26,7 @@ class ScenarioEFinanceEdgeTest extends TestCase
$parent = $this->createApiUserWithRole('parent');
$priorInvoiceId = $this->seedPriorYearUnpaidInvoice($parent->id);
$preview = $this->getJson('/api/v1/finance/carryforwards/preview?'.http_build_query([
$preview = $this->getJson('/api/v1/finance/carryforwards/preview?' . http_build_query([
'from_school_year' => self::E2E_PREV_SCHOOL_YEAR,
'to_school_year' => self::E2E_SCHOOL_YEAR,
'parent_id' => $parent->id,
@@ -44,24 +44,24 @@ class ScenarioEFinanceEdgeTest extends TestCase
$this->assertNotEmpty($draft->json('result.created'));
$carryforwardId = (int) $draft->json('result.created.0.id');
$this->postJson('/api/v1/finance/carryforwards/'.$carryforwardId.'/approve')
$this->postJson('/api/v1/finance/carryforwards/' . $carryforwardId . '/approve')
->assertOk()
->assertJsonPath('ok', true);
$posted = $this->postJson('/api/v1/finance/carryforwards/'.$carryforwardId.'/post')
$posted = $this->postJson('/api/v1/finance/carryforwards/' . $carryforwardId . '/post')
->assertOk()
->assertJsonPath('ok', true);
$newInvoiceId = (int) ($posted->json('carryforward.posted_invoice_id') ?? 0);
$this->assertGreaterThan(0, $newInvoiceId);
$plan = $this->postJson('/api/v1/finance/invoices/'.$newInvoiceId.'/installment-plans', [
$plan = $this->postJson('/api/v1/finance/invoices/' . $newInvoiceId . '/installment-plans', [
'number_of_installments' => 2,
'first_due_date' => '2025-09-15',
'interval_months' => 1,
])->assertCreated()->assertJsonPath('ok', true);
$planId = (int) $plan->json('plan.id');
$this->postJson('/api/v1/finance/installment-plans/'.$planId.'/activate')
$this->postJson('/api/v1/finance/installment-plans/' . $planId . '/activate')
->assertOk()
->assertJsonPath('ok', true);
@@ -81,7 +81,7 @@ class ScenarioEFinanceEdgeTest extends TestCase
'updated_at' => now(),
]);
$this->postJson('/api/v1/finance/payments/'.$paymentId.'/allocate-installments', [])
$this->postJson('/api/v1/finance/payments/' . $paymentId . '/allocate-installments', [])
->assertOk()
->assertJsonPath('ok', true);
@@ -89,20 +89,20 @@ class ScenarioEFinanceEdgeTest extends TestCase
->assertOk()
->assertJsonPath('ok', true);
$this->postJson('/api/v1/finance/parent-payment-followups/'.$parent->id.'/note', [
$this->postJson('/api/v1/finance/parent-payment-followups/' . $parent->id . '/note', [
'invoice_id' => $newInvoiceId,
'school_year' => self::E2E_SCHOOL_YEAR,
'note' => 'Called parent about prior-year balance.',
'contact_method' => 'phone',
])->assertCreated()->assertJsonPath('ok', true);
$this->postJson('/api/v1/finance/parent-payment-followups/'.$parent->id.'/promise-to-pay', [
$this->postJson('/api/v1/finance/parent-payment-followups/' . $parent->id . '/promise-to-pay', [
'invoice_id' => $newInvoiceId,
'promise_to_pay_date' => '2025-10-15',
'promise_to_pay_amount' => 50,
])->assertCreated()->assertJsonPath('ok', true);
$this->postJson('/api/v1/finance/parent-payment-followups/'.$parent->id.'/resolve', [
$this->postJson('/api/v1/finance/parent-payment-followups/' . $parent->id . '/resolve', [
'invoice_id' => $newInvoiceId,
'note' => 'Balance plan agreed.',
])->assertCreated()->assertJsonPath('ok', true);
@@ -3,6 +3,7 @@
namespace Tests\Feature\Api\V1\Workflows;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Mail;
use Tests\Concerns\SeedsE2ETestFixtures;
use Tests\TestCase;
@@ -36,7 +36,7 @@ class ScenarioGPrintBadgeTest extends TestCase
$this->actingAs($admin, 'api');
foreach (['assigned', 'done', 'delivered'] as $status) {
$this->patchJson('/api/v1/print-requests/'.$requestId.'/status', [
$this->patchJson('/api/v1/print-requests/' . $requestId . '/status', [
'status' => $status,
])->assertOk();
}
@@ -46,7 +46,7 @@ class ScenarioGPrintBadgeTest extends TestCase
'status' => 'delivered',
]);
$badgeTag = 'E2E-BADGE-'.random_int(10000, 99999);
$badgeTag = 'E2E-BADGE-' . random_int(10000, 99999);
DB::table('users')->where('id', $teacher->id)->update(['rfid_tag' => $badgeTag]);
$this->postJson('/api/v1/badges/log-print', [
@@ -29,7 +29,7 @@ class ScenarioHSecurityUnauthorizedAccessTest extends TestCase
// Parent cannot update another family's student.
$this->actingAs($parentA, 'api');
$this->patchJson('/api/v1/parents/students/'.$world['student_id'], [
$this->patchJson('/api/v1/parents/students/' . $world['student_id'], [
'firstname' => 'Blocked',
'lastname' => 'Update',
'dob' => '2014-01-01',
@@ -0,0 +1,130 @@
<?php
namespace Tests\Feature\Api\V1\Workflows;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Hash;
use Tests\Concerns\SeedsE2ETestFixtures;
use Tests\TestCase;
/**
* Scenario I: Public access, authentication lifecycle, and basic system probes.
*
* This keeps the front door honest before the deeper school workflows run.
*/
class ScenarioIPublicAuthAndSystemUseCaseTest extends TestCase
{
use RefreshDatabase;
use SeedsE2ETestFixtures;
public function test_guest_can_use_public_content_contact_and_health_endpoints_without_authentication(): void
{
$this->seedE2EConfiguration();
$publicGetEndpoints = [
'/api/v1/health',
'/api/v1/policies/school',
'/api/v1/policies/picture',
'/api/v1/pages/privacy',
'/api/v1/pages/terms',
'/api/v1/pages/help',
'/api/v1/frontend',
'/api/v1/frontend/facility',
'/api/v1/frontend/team',
'/api/v1/frontend/call-to-action',
'/api/v1/frontend/testimonial',
'/api/v1/auth/register/captcha',
];
foreach ($publicGetEndpoints as $endpoint) {
$response = $this->getJson($endpoint);
$this->assertLessThan(
500,
$response->getStatusCode(),
"$endpoint returned a server error. Public routes do not get to explode just because nobody logged in."
);
}
$this->postJson('/api/v1/contact', [])
->assertStatus(422)
->assertJsonValidationErrors(['name', 'email', 'subject', 'message']);
$this->postJson('/api/v1/contact', [
'name' => 'Public Visitor',
'email' => 'visitor@example.test',
'subject' => 'Program question',
'message' => 'Please send details about Sunday school registration.',
])
->assertCreated()
->assertJsonPath('status', true)
->assertJsonStructure(['status', 'message', 'data' => ['contact']]);
}
public function test_user_can_login_read_current_profile_and_logout_through_api(): void
{
$user = $this->createApiUserWithRole('teacher', [
'firstname' => 'E2E',
'lastname' => 'Teacher',
'email' => 'e2e.auth.teacher@example.test',
'password' => Hash::make('secret123'),
]);
$login = $this->postJson('/api/v1/auth/login', [
'email' => strtoupper($user->email),
'password' => 'secret123',
])
->assertOk()
->assertJsonPath('status', true)
->assertJsonStructure([
'token',
'access_token',
'token_type',
'expires_in',
'user' => ['id', 'name', 'firstname', 'lastname', 'email', 'roles'],
]);
$token = (string) $login->json('token');
$this->assertNotSame('', $token);
$this->withHeader('Authorization', 'Bearer ' . $token)
->getJson('/api/v1/auth/me')
->assertOk()
->assertJsonPath('status', true)
->assertJsonPath('data.email', 'e2e.auth.teacher@example.test');
$this->withHeader('Authorization', 'Bearer ' . $token)
->postJson('/api/v1/auth/logout')
->assertOk()
->assertJsonPath('status', true);
}
public function test_invalid_inactive_and_suspended_login_paths_fail_safely(): void
{
$active = $this->createApiUserWithRole('parent', [
'email' => 'active.parent@example.test',
'password' => Hash::make('secret123'),
'is_suspended' => 0,
]);
$this->postJson('/api/v1/auth/login', [
'email' => $active->email,
'password' => 'wrong-password',
])
->assertUnauthorized()
->assertJsonPath('status', false);
$suspended = $this->createApiUserWithRole('parent', [
'email' => 'suspended.parent@example.test',
'password' => Hash::make('secret123'),
'is_suspended' => 1,
]);
$this->postJson('/api/v1/auth/login', [
'email' => $suspended->email,
'password' => 'secret123',
])
->assertForbidden()
->assertJsonPath('status', false);
}
}
@@ -0,0 +1,127 @@
<?php
namespace Tests\Feature\Api\V1\Workflows;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\Concerns\SeedsE2ETestFixtures;
use Tests\TestCase;
/**
* Scenario J: Cross-module API smoke checks.
*
* These are intentionally use-case oriented and broad. They prove the major
* modules can be reached with realistic authentication and seeded school context.
* They do not pretend to replace each module's dedicated CRUD and edge tests.
*/
class ScenarioJCrossModuleApiUseCaseSmokeTest extends TestCase
{
use RefreshDatabase;
use SeedsE2ETestFixtures;
public function test_admin_can_reach_core_operational_dashboards_without_server_errors(): void
{
$world = $this->seedTeacherClassWithStudent();
$admin = $this->actingAsApiAdministrator();
$this->actingAs($admin, 'api');
$invoiceId = $this->seedInvoiceForParent($world['parent_id']);
$endpoints = [
'/api/v1/administrator/dashboard/metrics',
'/api/v1/administrator/dashboard/user-search?query=Kai',
'/api/v1/users',
'/api/v1/students',
'/api/v1/students/' . $world['student_id'],
'/api/v1/students/' . $world['student_id'] . '/classes',
'/api/v1/class-sections',
'/api/v1/teachers/classes?class_section_id=' . $world['class_section_id'],
'/api/v1/attendance/admin/daily?date=2025-10-05',
'/api/v1/attendance-comment-templates',
'/api/v1/finance/invoices/parent/' . $world['parent_id'],
'/api/v1/finance/invoices/' . $invoiceId . '/preview',
'/api/v1/finance/paypal-transactions',
'/api/v1/inventory/items',
'/api/v1/inventory/categories',
'/api/v1/messages',
'/api/v1/support',
'/api/v1/settings',
'/api/v1/system/semester-range/resolve?date=2025-10-05',
];
foreach ($endpoints as $endpoint) {
$response = $this->getJson($endpoint);
$this->assertLessThan(
500,
$response->getStatusCode(),
"$endpoint returned a server error for an authenticated admin."
);
}
}
public function test_major_mutating_use_cases_validate_bad_payloads_instead_of_crashing(): void
{
$world = $this->seedTeacherClassWithStudent();
$admin = $this->actingAsApiAdministrator();
$this->actingAs($admin, 'api');
$adminMutations = [
['POST', '/api/v1/users', []],
['POST', '/api/v1/students', []],
['POST', '/api/v1/class-sections', []],
['POST', '/api/v1/attendance-comment-templates', []],
['POST', '/api/v1/assignments', []],
['POST', '/api/v1/messages', []],
['POST', '/api/v1/support', []],
['POST', '/api/v1/inventory/items', []],
['POST', '/api/v1/settings/events', []],
['POST', '/api/v1/finance/payments', []],
['POST', '/api/v1/finance/refunds', []],
['POST', '/api/v1/finance/fees/tuition-breakdown', []],
['POST', '/api/v1/extra-charges', []],
['POST', '/api/v1/reports/slips/preview', []],
];
foreach ($adminMutations as [$method, $endpoint, $payload]) {
$response = $this->json($method, $endpoint, $payload);
$this->assertLessThan(
500,
$response->getStatusCode(),
"$method $endpoint crashed instead of validating/rejecting the request."
);
}
$this->actingAs($world['teacher'], 'api');
foreach ([
['POST', '/api/v1/attendance/teacher/submit', []],
['POST', '/api/v1/teacher/absence-vacation', []],
['POST', '/api/v1/teacher/class-progress-submit', []],
['POST', '/api/v1/scores/homework', []],
] as [$method, $endpoint, $payload]) {
$response = $this->json($method, $endpoint, $payload);
$this->assertLessThan(
500,
$response->getStatusCode(),
"$method $endpoint crashed for an authenticated teacher."
);
}
$this->actingAs($world['parent'], 'api');
foreach ([
['PATCH', '/api/v1/parents/profile', []],
['POST', '/api/v1/parents/emergency-contacts', []],
['POST', '/api/v1/parents/attendance-reports', []],
['POST', '/api/v1/parents/events/participation', []],
] as [$method, $endpoint, $payload]) {
$response = $this->json($method, $endpoint, $payload);
$this->assertLessThan(
500,
$response->getStatusCode(),
"$method $endpoint crashed for an authenticated parent."
);
}
}
}
@@ -0,0 +1,149 @@
<?php
namespace Tests\Feature\Api\V1\Workflows;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Tests\Concerns\SeedsE2ETestFixtures;
use Tests\TestCase;
/**
* Scenario K: Parent self-service and privacy boundaries.
*
* This verifies the parent portal as one complete use case: view household data,
* update contact details, manage emergency contacts, and stay boxed inside the
* authenticated family. Tiny detail, privacy. Apparently important.
*/
class ScenarioKParentSelfServiceAndPrivacyTest extends TestCase
{
use RefreshDatabase;
use SeedsE2ETestFixtures;
public function test_parent_reviews_household_data_and_updates_contact_details(): void
{
$world = $this->seedTeacherClassWithStudent();
$parent = $world['parent'];
$studentId = $world['student_id'];
$this->seedInvoiceForParent($parent->id);
DB::table('enrollments')->insert([
'parent_id' => $parent->id,
'student_id' => $studentId,
'school_year' => self::E2E_SCHOOL_YEAR,
'semester' => self::E2E_SEMESTER,
'enrollment_status' => 'enrolled',
'enrollment_date' => '2025-09-01',
]);
DB::table('attendance_data')->insert([
'class_id' => 3,
'class_section_id' => $world['class_section_id'],
'student_id' => $studentId,
'school_id' => 'S-E2E',
'date' => '2025-10-05',
'status' => 'present',
'semester' => self::E2E_SEMESTER,
'school_year' => self::E2E_SCHOOL_YEAR,
'created_at' => now(),
'updated_at' => now(),
]);
$this->actingAs($parent, 'api');
$this->getJson('/api/v1/parents/profile')
->assertOk()
->assertJsonPath('ok', true);
$this->patchJson('/api/v1/parents/profile', [
'firstname' => 'Updated',
'lastname' => 'Parent',
'cellphone' => '5557778888',
'address_street' => '99 Parent Way',
'city' => 'Brooklyn',
'state' => 'NY',
'zip' => '11215',
])->assertOk()->assertJsonPath('ok', true);
$this->assertDatabaseHas('users', [
'id' => $parent->id,
'firstname' => 'Updated',
'cellphone' => '5557778888',
]);
$this->postJson('/api/v1/parents/emergency-contacts', [
'name' => 'Amina Emergency',
'cellphone' => '5550001111',
'email' => 'amina.emergency@example.test',
'relation' => 'Aunt',
])->assertCreated()->assertJsonPath('ok', true);
$this->assertDatabaseHas('emergency_contacts', [
'parent_id' => $parent->id,
'emergency_contact_name' => 'Amina Emergency',
]);
$this->getJson('/api/v1/parents/emergency-contacts')
->assertOk()
->assertJsonPath('ok', true);
$this->getJson('/api/v1/parents/attendance?school_year=' . self::E2E_SCHOOL_YEAR)
->assertOk()
->assertJsonPath('ok', true);
$this->getJson('/api/v1/parents/invoices?school_year=' . self::E2E_SCHOOL_YEAR)
->assertOk()
->assertJsonPath('ok', true);
$this->getJson('/api/v1/parents/enrollments?school_year=' . self::E2E_SCHOOL_YEAR)
->assertOk()
->assertJsonPath('ok', true);
}
public function test_parent_cannot_mutate_another_family_student_or_emergency_contact(): void
{
$this->seedE2EConfiguration();
$admin = $this->actingAsApiAdministrator();
$parentA = $this->createApiUserWithRole('parent');
$parentB = $this->createApiUserWithRole('parent');
$this->actingAs($admin, 'api');
$studentB = $this->createStudentViaApi($parentB->id, [
'firstname' => 'Private',
'lastname' => 'Student',
]);
$foreignContactId = DB::table('emergency_contacts')->insertGetId([
'parent_id' => $parentB->id,
'emergency_contact_name' => 'Foreign Contact',
'cellphone' => '5553334444',
'email' => 'foreign.contact@example.test',
'relation' => 'Uncle',
'semester' => self::E2E_SEMESTER,
'school_year' => self::E2E_SCHOOL_YEAR,
'created_at' => now(),
'updated_at' => now(),
]);
$this->actingAs($parentA, 'api');
$this->patchJson('/api/v1/parents/students/' . $studentB, [
'firstname' => 'Tampered',
'lastname' => 'Student',
'dob' => '2015-01-01',
'gender' => 'Male',
])->assertNotFound();
$this->patchJson('/api/v1/parents/emergency-contacts/' . $foreignContactId, [
'name' => 'Tampered Contact',
'cellphone' => '5553334444',
'email' => 'foreign.contact@example.test',
'relation' => 'Uncle',
])->assertNotFound();
$this->assertDatabaseHas('students', [
'id' => $studentB,
'firstname' => 'Private',
]);
$this->assertDatabaseHas('emergency_contacts', [
'id' => $foreignContactId,
'emergency_contact_name' => 'Foreign Contact',
]);
}
}
@@ -0,0 +1,130 @@
<?php
namespace Tests\Feature\Api\V1\Workflows;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Tests\Concerns\SeedsE2ETestFixtures;
use Tests\TestCase;
/**
* Scenario L: Teacher academic operations.
*
* This walks a teacher through the practical classroom loop: roster context,
* progress reporting, scoring, and read-only reporting. A test that merely asks
* if a controller exists is not a workflow; it is a very expensive shrug.
*/
class ScenarioLTeacherAcademicOperationsTest extends TestCase
{
use RefreshDatabase;
use SeedsE2ETestFixtures;
public function test_teacher_manages_class_progress_and_homework_scores_end_to_end(): void
{
$world = $this->seedTeacherClassWithStudent();
$teacher = $world['teacher'];
$classSectionId = $world['class_section_id'];
$studentId = $world['student_id'];
$this->actingAs($teacher, 'api');
$this->getJson('/api/v1/teachers/classes?class_section_id=' . $classSectionId)
->assertOk()
->assertJsonPath('ok', true);
$this->getJson('/api/v1/class-progress/meta?class_id=3&school_year=' . self::E2E_SCHOOL_YEAR . '&semester=' . self::E2E_SEMESTER)
->assertOk()
->assertJsonPath('status', true);
$this->postJson('/api/v1/class-progress', [
'class_section_id' => $classSectionId,
'week_start' => '2025-10-05',
'week_end' => '2025-10-11',
'covered_islamic' => 'Seerah review',
'covered_quran' => 'Surah recitation practice',
'homework_islamic' => 'Read lesson notes',
'homework_quran' => 'Memorize assigned ayat',
'flags' => ['on_track'],
])->assertCreated()->assertJsonPath('status', true);
$reportId = (int) DB::table('class_progress_reports')
->where('teacher_id', $teacher->id)
->where('class_section_id', $classSectionId)
->orderBy('id')
->value('id');
$this->assertGreaterThan(0, $reportId);
$this->patchJson('/api/v1/class-progress/' . $reportId, [
'status' => 'ahead',
])->assertOk()->assertJsonPath('status', true);
$this->postJson('/api/v1/scores/homework/columns', [
'class_section_id' => $classSectionId,
'semester' => self::E2E_SEMESTER,
'school_year' => self::E2E_SCHOOL_YEAR,
])->assertOk()->assertJsonPath('ok', true);
$this->postJson('/api/v1/scores/homework', [
'class_section_id' => $classSectionId,
'semester' => self::E2E_SEMESTER,
'school_year' => self::E2E_SCHOOL_YEAR,
'scores' => [
(string) $studentId => [
'1' => 96,
],
],
'missing_ok' => [],
])->assertOk()->assertJsonPath('ok', true);
$this->assertDatabaseHas('homework', [
'student_id' => $studentId,
'class_section_id' => $classSectionId,
'homework_index' => 1,
'score' => 96,
'school_year' => self::E2E_SCHOOL_YEAR,
]);
$this->getJson('/api/v1/scores/homework?class_section_id=' . $classSectionId . '&semester=' . self::E2E_SEMESTER . '&school_year=' . self::E2E_SCHOOL_YEAR)
->assertOk()
->assertJsonPath('ok', true);
$this->getJson('/api/v1/reports/report-cards/completeness?class_section_id=' . $classSectionId . '&semester=' . self::E2E_SEMESTER . '&school_year=' . self::E2E_SCHOOL_YEAR)
->assertOk()
->assertJsonPath('status', true);
}
public function test_unassigned_teacher_cannot_view_or_update_another_teachers_progress_report(): void
{
$world = $this->seedTeacherClassWithStudent();
$otherTeacher = $this->createApiUserWithRole('teacher');
DB::table('class_progress_reports')->insert([
'teacher_id' => $world['teacher']->id,
'class_section_id' => $world['class_section_id'],
'subject' => 'Islamic Studies',
'week_start' => '2025-10-05',
'week_end' => '2025-10-11',
'covered' => 'Private teacher notes',
'homework' => 'Private homework',
'status' => 'on_track',
'next_week_plan' => 'Continue private unit',
'created_at' => now(),
'updated_at' => now(),
]);
$reportId = (int) DB::table('class_progress_reports')->value('id');
$this->actingAs($otherTeacher, 'api');
$this->getJson('/api/v1/class-progress/' . $reportId)
->assertForbidden();
$this->patchJson('/api/v1/class-progress/' . $reportId, [
'status' => 'behind',
])->assertForbidden();
$this->assertDatabaseHas('class_progress_reports', [
'id' => $reportId,
'status' => 'on_track',
]);
}
}
@@ -0,0 +1,134 @@
<?php
namespace Tests\Feature\Api\V1\Workflows;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Tests\Concerns\SeedsE2ETestFixtures;
use Tests\TestCase;
/**
* Scenario M: Administrator enrollment and classroom operations.
*
* This is the operational spine: parent account, student registration, class
* section setup, student placement, teacher assignment, roster visibility, and
* reversal paths. If this breaks, school administration becomes archaeology.
*/
class ScenarioMAdministratorEnrollmentAndClassOperationsTest extends TestCase
{
use RefreshDatabase;
use SeedsE2ETestFixtures;
public function test_admin_builds_classroom_roster_from_parent_registration_to_teacher_assignment(): void
{
$this->seedE2EConfiguration();
$admin = $this->actingAsApiAdministrator();
$teacher = $this->createApiUserWithRole('teacher', [
'firstname' => 'Maryam',
'lastname' => 'Teacher',
]);
$this->actingAs($admin, 'api');
$parentId = $this->createParentAccountViaApi([
'firstname' => 'Roster',
'lastname' => 'Parent',
]);
$studentId = $this->createStudentViaApi($parentId, [
'firstname' => 'Roster',
'lastname' => 'Student',
]);
DB::table('classes')->insert([
'id' => 9,
'class_name' => 'Grade 9',
'schedule' => 'Sunday',
'capacity' => 25,
'semester' => self::E2E_SEMESTER,
'school_year' => self::E2E_SCHOOL_YEAR,
'created_at' => now(),
'updated_at' => now(),
]);
$this->postJson('/api/v1/class-sections', [
'class_id' => 9,
'class_section_id' => 909,
'class_section_name' => '9A',
'semester' => self::E2E_SEMESTER,
'school_year' => self::E2E_SCHOOL_YEAR,
])->assertCreated();
$this->assignStudentToClassViaApi($studentId, 909)
->assertOk()
->assertJsonPath('ok', true);
$this->postJson('/api/v1/administrator/teacher-class/assign', [
'teacher_id' => $teacher->id,
'class_section_id' => 909,
'teacher_role' => 'teacher',
'school_year' => self::E2E_SCHOOL_YEAR,
])->assertOk()->assertJsonPath('ok', true);
$this->assertDatabaseHas('student_class', [
'student_id' => $studentId,
'class_section_id' => 909,
'school_year' => self::E2E_SCHOOL_YEAR,
]);
$this->assertDatabaseHas('teacher_class', [
'teacher_id' => $teacher->id,
'class_section_id' => 909,
'position' => 'main',
'school_year' => self::E2E_SCHOOL_YEAR,
]);
$this->getJson('/api/v1/students/' . $studentId . '/classes')
->assertOk()
->assertJsonPath('ok', true);
$this->actingAs($teacher, 'api');
$this->getJson('/api/v1/teachers/classes?class_section_id=909')
->assertOk()
->assertJsonPath('ok', true);
}
public function test_admin_can_reverse_roster_and_teacher_assignments_without_orphaning_records(): void
{
$this->seedE2EConfiguration();
$admin = $this->actingAsApiAdministrator();
$teacher = $this->createApiUserWithRole('teacher');
$classSectionId = $this->seedClassSection(910, '9B', 9);
$parentId = $this->createParentAccountViaApi();
$studentId = $this->createStudentViaApi($parentId);
$this->actingAs($admin, 'api');
$this->assignStudentToClassViaApi($studentId, $classSectionId)->assertOk();
$this->seedTeacherClassAssignment($teacher->id, $classSectionId);
$this->postJson('/api/v1/students/remove-class', [
'student_id' => $studentId,
'class_section_id' => $classSectionId,
])->assertOk()->assertJsonPath('ok', true);
$this->postJson('/api/v1/administrator/teacher-class/delete', [
'teacher_id' => $teacher->id,
'class_section_id' => $classSectionId,
'position' => 'main',
'school_year' => self::E2E_SCHOOL_YEAR,
])->assertOk()->assertJsonPath('ok', true);
$this->assertDatabaseMissing('student_class', [
'student_id' => $studentId,
'class_section_id' => $classSectionId,
'school_year' => self::E2E_SCHOOL_YEAR,
]);
$this->assertDatabaseMissing('teacher_class', [
'teacher_id' => $teacher->id,
'class_section_id' => $classSectionId,
'position' => 'main',
'school_year' => self::E2E_SCHOOL_YEAR,
]);
$this->assertDatabaseHas('students', [
'id' => $studentId,
'parent_id' => $parentId,
]);
}
}
@@ -0,0 +1,99 @@
<?php
namespace Tests\Feature\Api\V1\Workflows;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Tests\Concerns\SeedsE2ETestFixtures;
use Tests\TestCase;
/**
* Scenario N: Communications and support lifecycle.
*
* A real school app is not just invoices and rosters. People send messages,
* support requests, notifications, and reports. Then everyone acts shocked when
* privacy and read-state bugs matter. This nails those flows down.
*/
class ScenarioNCommunicationsAndSupportLifecycleTest extends TestCase
{
use RefreshDatabase;
use SeedsE2ETestFixtures;
public function test_parent_and_teacher_exchange_message_and_read_state_is_preserved(): void
{
$world = $this->seedTeacherClassWithStudent();
$parent = $world['parent'];
$teacher = $world['teacher'];
$this->actingAs($parent, 'api');
$this->postJson('/api/v1/messages', [
'recipient_id' => $teacher->id,
'subject' => 'Homework clarification',
'message' => 'Can you confirm the Quran homework for this week?',
'priority' => 'normal',
])->assertCreated();
$messageId = (int) DB::table('messages')
->where('sender_id', $parent->id)
->where('recipient_id', $teacher->id)
->where('subject', 'Homework clarification')
->value('id');
$this->assertGreaterThan(0, $messageId);
$this->actingAs($teacher, 'api');
$this->getJson('/api/v1/messages/inbox')
->assertOk()
->assertJsonPath('ok', true);
$this->getJson('/api/v1/messages/' . $messageId)
->assertOk()
->assertJsonPath('ok', true);
$this->assertDatabaseHas('messages', [
'id' => $messageId,
'read_status' => 1,
]);
$this->patchJson('/api/v1/messages/' . $messageId, [
'status' => 'archived',
])->assertOk()->assertJsonPath('ok', true);
$this->assertDatabaseHas('messages', [
'id' => $messageId,
'status' => 'archived',
]);
}
public function test_public_contact_support_ticket_and_admin_support_queue_are_connected(): void
{
$this->seedE2EConfiguration();
$admin = $this->actingAsApiAdministrator();
$this->postJson('/api/v1/contact', [
'name' => 'Public Visitor',
'email' => 'visitor.support@example.test',
'subject' => 'Registration help',
'message' => 'I need help completing registration.',
])->assertSuccessful();
$this->actingAs($admin, 'api');
$this->getJson('/api/v1/support')
->assertOk()
->assertJsonPath('ok', true);
$response = $this->postJson('/api/v1/support', [
'name' => 'Internal Support Case',
'email' => 'internal.support@example.test',
'subject' => 'Follow up required',
'message' => 'Please call the parent about registration.',
'status' => 'open',
'priority' => 'normal',
]);
$this->assertLessThan(
500,
$response->getStatusCode(),
'Authenticated support ticket creation should validate or persist, not crash.'
);
}
}
@@ -0,0 +1,115 @@
<?php
namespace Tests\Feature\Api\V1\Workflows;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Tests\Concerns\SeedsE2ETestFixtures;
use Tests\TestCase;
/**
* Scenario O: Inventory, finance, and audit controls.
*
* This covers the unglamorous back office: materials are created, adjusted,
* distributed, billed, paid, and then reported. Not glamorous. Still where money
* leaks if tests are just decorative plants.
*/
class ScenarioOInventoryFinanceAndAuditControlsTest extends TestCase
{
use RefreshDatabase;
use SeedsE2ETestFixtures;
public function test_admin_tracks_inventory_item_from_creation_to_adjustment_and_summary(): void
{
$this->seedE2EConfiguration();
$admin = $this->actingAsApiAdministrator();
$this->actingAs($admin, 'api');
$categoryId = DB::table('inventory_categories')->insertGetId([
'type' => 'classroom',
'name' => 'E2E Classroom Supplies',
'created_at' => now(),
'updated_at' => now(),
]);
$this->postJson('/api/v1/inventory/items', [
'type' => 'classroom',
'category_id' => $categoryId,
'name' => 'Dry erase markers',
'quantity' => 12,
'condition' => 'good',
'semester' => self::E2E_SEMESTER,
'school_year' => self::E2E_SCHOOL_YEAR,
])->assertCreated();
$itemId = (int) DB::table('inventory_items')
->where('name', 'Dry erase markers')
->value('id');
$this->assertGreaterThan(0, $itemId);
$this->postJson('/api/v1/inventory/items/' . $itemId . '/adjust', [
'mode' => 'out',
'quantity' => 2,
'reason' => 'Issued to teacher cart',
])->assertOk()->assertJsonPath('status', true);
$this->postJson('/api/v1/inventory/items/' . $itemId . '/audit', [
'needs_repair_qty' => 1,
'need_replace_qty' => 0,
'cannot_find_qty' => 0,
])->assertOk()->assertJsonPath('status', true);
$this->assertDatabaseHas('inventory_movements', [
'item_id' => $itemId,
'movement_type' => 'out',
]);
$this->assertDatabaseHas('inventory_items', [
'id' => $itemId,
'needs_repair_qty' => 1,
]);
$this->getJson('/api/v1/inventory/summary/classroom')
->assertOk()
->assertJsonPath('status', true);
}
public function test_finance_followup_records_notes_promises_and_resolution_for_unpaid_parent(): void
{
$world = $this->seedTeacherClassWithStudent();
$admin = $this->actingAsApiAdministrator();
$parentId = $world['parent_id'];
$invoiceId = $this->seedInvoiceForParent($parentId, [
'balance' => 300.00,
'paid_amount' => 200.00,
'status' => 'partial',
]);
$this->actingAs($admin, 'api');
$this->getJson('/api/v1/finance/parent-payment-followups')
->assertOk()
->assertJsonPath('ok', true);
$this->postJson('/api/v1/finance/parent-payment-followups/' . $parentId . '/note', [
'note' => 'Left voicemail about remaining balance.',
'invoice_id' => $invoiceId,
])->assertSuccessful();
$this->postJson('/api/v1/finance/parent-payment-followups/' . $parentId . '/promise-to-pay', [
'invoice_id' => $invoiceId,
'amount' => 300.00,
'promise_date' => '2025-10-20',
'note' => 'Parent promised to pay next Sunday.',
])->assertSuccessful();
$this->postJson('/api/v1/finance/parent-payment-followups/' . $parentId . '/mark-contacted', [
'invoice_id' => $invoiceId,
'contacted_at' => '2025-10-06 12:00:00',
])->assertSuccessful();
$this->postJson('/api/v1/finance/parent-payment-followups/' . $parentId . '/resolve', [
'invoice_id' => $invoiceId,
'resolution_note' => 'Resolved after payment arrangement.',
])->assertSuccessful();
}
}
@@ -0,0 +1,139 @@
<?php
namespace Tests\Feature\Api\V1\Workflows;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Tests\Concerns\AssertsE2EApiResponses;
use Tests\Concerns\SeedsE2ETestFixtures;
use Tests\TestCase;
/**
* Scenario P: Settings, configuration, calendar, and notification governance.
*
* This verifies that operational settings can be reviewed, changed, and audited
* through the API instead of being tribal knowledge in someone's browser tab.
*/
class ScenarioPSettingsConfigurationAndCalendarGovernanceTest extends TestCase
{
use RefreshDatabase;
use SeedsE2ETestFixtures;
use AssertsE2EApiResponses;
public function test_admin_manages_calendar_event_configuration_and_notification_lifecycle(): void
{
$this->seedE2EConfiguration();
$admin = $this->actingAsApiAdministrator();
$this->actingAs($admin, 'api');
$this->getJson('/api/v1/settings')
->assertOk();
$settingsUpdate = $this->patchJson('/api/v1/settings', [
'timezone' => 'America/New_York',
'school_name' => 'Al-Rahma Test School',
'default_semester' => self::E2E_SEMESTER,
'default_school_year' => self::E2E_SCHOOL_YEAR,
]);
$this->assertNoServerError($settingsUpdate, 'Settings update');
$this->getJson('/api/v1/settings/school-calendar/options')
->assertOk()
->assertJsonPath('status', true);
$event = $this->postJson('/api/v1/settings/school-calendar/events', [
'title' => 'E2E Parent Orientation',
'date' => '2025-10-12',
'description' => 'Orientation for new families.',
'audience' => 'parents',
'semester' => self::E2E_SEMESTER,
'school_year' => self::E2E_SCHOOL_YEAR,
]);
$event->assertCreated()->assertJsonPath('status', true);
$eventId = (int) DB::table('calendar_events')
->where('title', 'E2E Parent Orientation')
->value('id');
$this->assertGreaterThan(0, $eventId);
$this->getJson('/api/v1/settings/school-calendar/events?school_year=' . self::E2E_SCHOOL_YEAR . '&include_meetings=1')
->assertOk()
->assertJsonPath('status', true);
$this->patchJson('/api/v1/settings/school-calendar/events/' . $eventId, [
'title' => 'E2E Parent Orientation Updated',
'date' => '2025-10-19',
'description' => 'Updated orientation for new families.',
'audience' => 'parents',
'semester' => self::E2E_SEMESTER,
'school_year' => self::E2E_SCHOOL_YEAR,
])->assertOk()->assertJsonPath('status', true);
$notification = $this->postJson('/api/v1/notifications', [
'title' => 'Orientation Reminder',
'message' => 'Parent orientation moved to October 19.',
'audience' => 'parents',
'type' => 'announcement',
'starts_at' => '2025-10-10 09:00:00',
'ends_at' => '2025-10-20 09:00:00',
'is_active' => true,
]);
$this->assertSuccessfulOrValidation($notification, 'Notification creation');
$notificationId = (int) DB::table('notifications')
->where('title', 'Orientation Reminder')
->value('id');
if ($notificationId > 0) {
$this->getJson('/api/v1/notifications/active')
->assertOk();
$this->postJson('/api/v1/notifications/' . $notificationId . '/read')
->assertSuccessful();
$this->deleteJson('/api/v1/notifications/' . $notificationId)
->assertSuccessful();
$this->postJson('/api/v1/notifications/' . $notificationId . '/restore')
->assertSuccessful();
}
$this->deleteJson('/api/v1/settings/school-calendar/events/' . $eventId)
->assertOk()
->assertJsonPath('status', true);
}
public function test_configuration_admin_crud_keeps_school_year_context_explicit(): void
{
$this->seedE2EConfiguration();
$admin = $this->actingAsApiAdministrator();
$this->actingAs($admin, 'api');
$this->getJson('/api/v1/configuration-admin')
->assertOk();
$create = $this->postJson('/api/v1/configuration-admin', [
'config_key' => 'e2e_guardrail_message',
'config_value' => 'E2E guardrail enabled',
'description' => 'Created by explicit workflow coverage.',
]);
$this->assertSuccessfulOrValidation($create, 'Configuration creation');
$configId = (int) DB::table('configuration')
->where('config_key', 'e2e_guardrail_message')
->value('id');
$this->getJson('/api/v1/configuration-admin/key/e2e_guardrail_message')
->assertSuccessful();
if ($configId > 0) {
$this->patchJson('/api/v1/configuration-admin/' . $configId, [
'config_value' => 'E2E guardrail updated',
'description' => 'Updated by explicit workflow coverage.',
])->assertSuccessful();
$this->deleteJson('/api/v1/configuration-admin/' . $configId)
->assertSuccessful();
}
}
}
@@ -0,0 +1,151 @@
<?php
namespace Tests\Feature\Api\V1\Workflows;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Tests\Concerns\AssertsE2EApiResponses;
use Tests\Concerns\SeedsE2ETestFixtures;
use Tests\TestCase;
/**
* Scenario Q: School-year closure and promotion lifecycle.
*
* This pins down the dangerous annual transition: promotion decisions, parent
* enrollment steps, close-preview, close, reopen, and read-only protection.
*/
class ScenarioQSchoolYearPromotionClosureLifecycleTest extends TestCase
{
use RefreshDatabase;
use SeedsE2ETestFixtures;
use AssertsE2EApiResponses;
public function test_admin_and_parent_work_through_promotion_before_year_close(): void
{
$world = $this->seedTeacherClassWithStudent();
$admin = $this->actingAsApiAdministrator();
$parent = $world['parent'];
$studentId = $world['student_id'];
DB::table('enrollments')->insert([
'parent_id' => $parent->id,
'student_id' => $studentId,
'school_year' => self::E2E_SCHOOL_YEAR,
'semester' => self::E2E_SEMESTER,
'enrollment_status' => 'enrolled',
'enrollment_date' => '2025-09-01',
'created_at' => now(),
'updated_at' => now(),
]);
$this->actingAs($admin, 'api');
$this->getJson('/api/v1/administrator/promotions')
->assertOk();
$this->getJson('/api/v1/administrator/promotions/summary')
->assertOk();
$evaluate = $this->postJson('/api/v1/administrator/promotions/evaluate', [
'school_year' => self::E2E_SCHOOL_YEAR,
'student_ids' => [$studentId],
]);
$this->assertNoServerError($evaluate, 'Promotion evaluation');
$promotionId = (int) DB::table('student_promotion_records')
->where('student_id', $studentId)
->value('id');
if ($promotionId > 0) {
$this->patchJson('/api/v1/administrator/promotions/' . $promotionId . '/status', [
'status' => 'parent_action_required',
'decision' => 'promote',
'notes' => 'Ready for next level.',
])->assertSuccessful();
$this->actingAs($parent, 'api');
$this->getJson('/api/v1/parents/promotions')
->assertOk();
$this->postJson('/api/v1/parents/promotions/' . $promotionId . '/start')
->assertSuccessful();
$this->patchJson('/api/v1/parents/promotions/' . $promotionId . '/steps', [
'confirm_returning' => true,
'confirm_contact_info' => true,
'confirm_policy' => true,
])->assertSuccessful();
$this->postJson('/api/v1/parents/promotions/' . $promotionId . '/submit')
->assertSuccessful();
}
}
public function test_admin_previews_closes_and_reopens_school_year_with_balance_transfer_guardrails(): void
{
$world = $this->seedTeacherClassWithStudent();
$admin = $this->actingAsApiAdministrator();
$parentId = $world['parent_id'];
$studentId = $world['student_id'];
$this->seedInvoiceForParent($parentId, [
'invoice_number' => 'INV-CLOSE-' . random_int(1000, 9999),
'balance' => 125.00,
'paid_amount' => 375.00,
'total_amount' => 500.00,
'status' => 'partial',
]);
$activeYearId = (int) DB::table('school_years')
->where('name', self::E2E_SCHOOL_YEAR)
->value('id');
$this->assertGreaterThan(0, $activeYearId);
DB::table('enrollments')->updateOrInsert(
['student_id' => $studentId, 'school_year' => self::E2E_SCHOOL_YEAR],
[
'parent_id' => $parentId,
'semester' => self::E2E_SEMESTER,
'enrollment_status' => 'enrolled',
'enrollment_date' => '2025-09-01',
'created_at' => now(),
'updated_at' => now(),
]
);
$this->actingAs($admin, 'api');
$this->postJson('/api/v1/school-years/' . $activeYearId . '/validate-close', [
'new_school_year' => [
'name' => '2026-2027',
'start_date' => '2026-09-01',
'end_date' => '2027-06-30',
],
'transfer_unpaid_balances' => true,
])->assertSuccessful();
$this->postJson('/api/v1/school-years/' . $activeYearId . '/preview-close', [
'new_school_year' => [
'name' => '2026-2027',
'start_date' => '2026-09-01',
'end_date' => '2027-06-30',
],
'transfer_unpaid_balances' => true,
])->assertSuccessful();
$close = $this->postJson('/api/v1/school-years/' . $activeYearId . '/close', [
'new_school_year' => [
'name' => '2026-2027',
'start_date' => '2026-09-01',
'end_date' => '2027-06-30',
],
'transfer_unpaid_balances' => true,
'confirmation' => 'CLOSE ' . self::E2E_SCHOOL_YEAR,
]);
$this->assertNoServerError($close, 'School year close');
$newYearId = (int) DB::table('school_years')
->where('name', '2026-2027')
->value('id');
if ($newYearId > 0) {
$this->postJson('/api/v1/school-years/' . $newYearId . '/reopen')
->assertSuccessful();
}
}
}
@@ -0,0 +1,175 @@
<?php
namespace Tests\Feature\Api\V1\Workflows;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\DB;
use Tests\Concerns\AssertsE2EApiResponses;
use Tests\Concerns\SeedsE2ETestFixtures;
use Tests\TestCase;
/**
* Scenario R: Attendance operations and exception handling.
*
* This covers the actual attendance day: teacher grid, submission, admin edits,
* late slips, early dismissal, follow-up, violation tracking, and templates.
*/
class ScenarioRAttendanceOperationsAndExceptionHandlingTest extends TestCase
{
use RefreshDatabase;
use SeedsE2ETestFixtures;
use AssertsE2EApiResponses;
public function test_teacher_admin_and_staff_handle_attendance_day_end_to_end(): void
{
$world = $this->seedTeacherClassWithStudent();
$teacher = $world['teacher'];
$admin = $this->actingAsApiAdministrator();
$studentId = $world['student_id'];
$classSectionId = $world['class_section_id'];
$this->actingAs($teacher, 'api');
$this->getJson('/api/v1/attendance/teacher/grid?class_section_id=' . $classSectionId . '&date=2025-10-05')
->assertSuccessful();
$this->getJson('/api/v1/attendance/teacher/form?class_section_id=' . $classSectionId . '&date=2025-10-05')
->assertSuccessful();
$submit = $this->postJson('/api/v1/attendance/teacher/submit', [
'class_section_id' => $classSectionId,
'date' => '2025-10-05',
'attendance' => [
[
'student_id' => $studentId,
'status' => 'present',
'comment' => 'On time',
],
],
'semester' => self::E2E_SEMESTER,
'school_year' => self::E2E_SCHOOL_YEAR,
]);
$this->assertNoServerError($submit, 'Teacher attendance submit');
$this->actingAs($admin, 'api');
$this->getJson('/api/v1/attendance/admin/daily?date=2025-10-05&class_section_id=' . $classSectionId)
->assertSuccessful();
$manualAdd = $this->postJson('/api/v1/attendance/admin/add-entry', [
'student_id' => $studentId,
'class_section_id' => $classSectionId,
'date' => '2025-10-05',
'status' => 'late',
'comment' => 'Arrived after assembly.',
'semester' => self::E2E_SEMESTER,
'school_year' => self::E2E_SCHOOL_YEAR,
]);
$this->assertNoServerError($manualAdd, 'Admin attendance manual entry');
$this->getJson('/api/v1/attendance/staff/month?month=2025-10')
->assertSuccessful();
$this->getJson('/api/v1/attendance/staff/admins')
->assertSuccessful();
$staffCell = $this->postJson('/api/v1/attendance/staff/cell', [
'user_id' => $admin->id,
'date' => '2025-10-05',
'status' => 'present',
'school_year' => self::E2E_SCHOOL_YEAR,
]);
$this->assertNoServerError($staffCell, 'Staff attendance cell save');
}
public function test_early_dismissal_late_slip_and_violation_tracking_are_connected(): void
{
$world = $this->seedTeacherClassWithStudent();
$admin = $this->actingAsApiAdministrator();
$studentId = $world['student_id'];
$classSectionId = $world['class_section_id'];
$this->actingAs($admin, 'api');
$this->getJson('/api/v1/attendance/early-dismissals/student-options')
->assertSuccessful();
$dismissal = $this->postJson('/api/v1/attendance/early-dismissals', [
'student_id' => $studentId,
'class_section_id' => $classSectionId,
'dismissal_date' => '2025-10-05',
'dismissal_time' => '12:10',
'reason' => 'Medical appointment',
'released_to' => 'Parent Fixture',
'relationship' => 'Parent',
]);
$this->assertNoServerError($dismissal, 'Early dismissal creation');
$signature = $this->post('/api/v1/attendance/early-dismissals/signature', [
'student_id' => $studentId,
'signature' => UploadedFile::fake()->image('signature.png', 200, 100),
]);
$this->assertSuccessfulOrValidation($signature, 'Early dismissal signature upload');
$this->getJson('/api/v1/attendance/late-slip-logs')
->assertSuccessful();
$record = $this->postJson('/api/v1/attendance-tracking/record', [
'student_id' => $studentId,
'class_section_id' => $classSectionId,
'date' => '2025-10-05',
'type' => 'late',
'status' => 'pending',
'note' => 'Second late arrival this month.',
'school_year' => self::E2E_SCHOOL_YEAR,
'semester' => self::E2E_SEMESTER,
]);
$this->assertNoServerError($record, 'Attendance tracking record');
$this->getJson('/api/v1/attendance-tracking/pending-violations')
->assertSuccessful();
$this->getJson('/api/v1/attendance-tracking/student-case/' . $studentId)
->assertSuccessful();
$this->getJson('/api/v1/attendance-tracking/compose?student_id=' . $studentId)
->assertSuccessful();
$note = $this->postJson('/api/v1/attendance-tracking/save-notification-note', [
'student_id' => $studentId,
'note' => 'Parent notified about attendance pattern.',
'date' => '2025-10-05',
]);
$this->assertNoServerError($note, 'Attendance tracking note');
}
public function test_attendance_comment_templates_support_create_update_delete_and_legacy_aliases(): void
{
$this->seedE2EConfiguration();
$admin = $this->actingAsApiAdministrator();
$this->actingAs($admin, 'api');
$create = $this->postJson('/api/v1/attendance-comment-templates', [
'comment' => 'E2E: arrived after first lesson.',
'type' => 'late',
'is_active' => true,
]);
$create->assertCreated();
$templateId = (int) DB::table('attendance_comment_templates')
->where('comment', 'E2E: arrived after first lesson.')
->value('id');
$this->assertGreaterThan(0, $templateId);
$this->getJson('/api/v1/attendance-comment-templates/list-data')
->assertOk();
$this->putJson('/api/v1/attendance-comment-templates/' . $templateId, [
'comment' => 'E2E: arrived late, parent notified.',
'type' => 'late',
'is_active' => true,
])->assertSuccessful();
$this->postJson('/api/v1/attendance-templates/save', [
'id' => $templateId,
'comment' => 'Legacy alias update',
'type' => 'late',
])->assertSuccessful();
$this->deleteJson('/api/v1/attendance-comment-templates/' . $templateId)
->assertSuccessful();
}
}
@@ -0,0 +1,131 @@
<?php
namespace Tests\Feature\Api\V1\Workflows;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Tests\Concerns\AssertsE2EApiResponses;
use Tests\Concerns\SeedsE2ETestFixtures;
use Tests\TestCase;
/**
* Scenario S: Scores, grading, placement, and report cards.
*
* This walks from teacher-entered scores to admin grading decisions and report
* card readiness. Because grades are where "just trust the UI" goes to die.
*/
class ScenarioSScoringGradingAndReportCardLifecycleTest extends TestCase
{
use RefreshDatabase;
use SeedsE2ETestFixtures;
use AssertsE2EApiResponses;
public function test_teacher_records_every_score_type_and_admin_reviews_grading_outputs(): void
{
$world = $this->seedTeacherClassWithStudent();
$teacher = $world['teacher'];
$studentId = $world['student_id'];
$classSectionId = $world['class_section_id'];
$admin = $this->actingAsApiAdministrator();
$this->actingAs($teacher, 'api');
foreach (['homework', 'quizzes', 'projects'] as $type) {
$column = $this->postJson('/api/v1/scores/' . $type . '/columns', [
'class_section_id' => $classSectionId,
'semester' => self::E2E_SEMESTER,
'school_year' => self::E2E_SCHOOL_YEAR,
]);
$this->assertNoServerError($column, ucfirst($type) . ' score column creation');
$save = $this->postJson('/api/v1/scores/' . $type, [
'class_section_id' => $classSectionId,
'semester' => self::E2E_SEMESTER,
'school_year' => self::E2E_SCHOOL_YEAR,
'scores' => [
(string) $studentId => ['1' => 91],
],
'missing_ok' => [],
]);
$this->assertNoServerError($save, ucfirst($type) . ' score save');
}
foreach (['midterm', 'final', 'participation'] as $type) {
$save = $this->postJson('/api/v1/scores/' . $type, [
'class_section_id' => $classSectionId,
'semester' => self::E2E_SEMESTER,
'school_year' => self::E2E_SCHOOL_YEAR,
'scores' => [
(string) $studentId => 88,
],
]);
$this->assertNoServerError($save, ucfirst($type) . ' score save');
}
$this->postJson('/api/v1/scores/comments', [
'student_id' => $studentId,
'class_section_id' => $classSectionId,
'school_year' => self::E2E_SCHOOL_YEAR,
'semester' => self::E2E_SEMESTER,
'comment' => 'Consistent progress across the semester.',
])->assertSuccessful();
$this->getJson('/api/v1/scores/overview?class_section_id=' . $classSectionId . '&school_year=' . self::E2E_SCHOOL_YEAR)
->assertSuccessful();
$this->getJson('/api/v1/scores/student-scores?student_id=' . $studentId . '&class_section_id=' . $classSectionId)
->assertSuccessful();
$this->actingAs($admin, 'api');
$this->getJson('/api/v1/grading/overview?class_section_id=' . $classSectionId)
->assertSuccessful();
$this->postJson('/api/v1/grading/decisions/generate', [
'class_section_id' => $classSectionId,
'school_year' => self::E2E_SCHOOL_YEAR,
'semester' => self::E2E_SEMESTER,
])->assertSuccessful();
$this->getJson('/api/v1/grading/decisions?class_section_id=' . $classSectionId)
->assertSuccessful();
$this->getJson('/api/v1/reports/report-cards/completeness?class_section_id=' . $classSectionId . '&school_year=' . self::E2E_SCHOOL_YEAR . '&semester=' . self::E2E_SEMESTER)
->assertSuccessful();
}
public function test_grading_locks_and_below_sixty_intervention_are_visible_and_mutable(): void
{
$world = $this->seedTeacherClassWithStudent();
$admin = $this->actingAsApiAdministrator();
$studentId = $world['student_id'];
$classSectionId = $world['class_section_id'];
$this->actingAs($admin, 'api');
$this->postJson('/api/v1/grading/locks/toggle', [
'type' => 'homework',
'class_section_id' => $classSectionId,
'school_year' => self::E2E_SCHOOL_YEAR,
'semester' => self::E2E_SEMESTER,
'locked' => true,
])->assertSuccessful();
$this->postJson('/api/v1/grading/locks/all', [
'class_section_id' => $classSectionId,
'school_year' => self::E2E_SCHOOL_YEAR,
'semester' => self::E2E_SEMESTER,
'locked' => false,
])->assertSuccessful();
$this->getJson('/api/v1/grading/below-sixty?school_year=' . self::E2E_SCHOOL_YEAR)
->assertSuccessful();
$this->postJson('/api/v1/grading/below-sixty/decisions', [
'student_id' => $studentId,
'class_section_id' => $classSectionId,
'decision' => 'monitor',
'notes' => 'E2E intervention note.',
'school_year' => self::E2E_SCHOOL_YEAR,
'semester' => self::E2E_SEMESTER,
])->assertSuccessful();
$this->postJson('/api/v1/grading/below-sixty/meeting', [
'student_id' => $studentId,
'meeting_date' => '2025-10-12',
'notes' => 'Parent meeting scheduled.',
])->assertSuccessful();
}
}
@@ -0,0 +1,132 @@
<?php
namespace Tests\Feature\Api\V1\Workflows;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Tests\Concerns\AssertsE2EApiResponses;
use Tests\Concerns\SeedsE2ETestFixtures;
use Tests\TestCase;
/**
* Scenario T: Documents, print requests, badges, stickers, slips, and certificates.
*
* This tests the school paper factory: upload, queue, admin processing, badge
* generation, certificate audit, and report exports. Bureaucracy, now with JSON.
*/
class ScenarioTDocumentsPrintBadgesAndCertificatesLifecycleTest extends TestCase
{
use RefreshDatabase;
use SeedsE2ETestFixtures;
use AssertsE2EApiResponses;
public function test_teacher_print_request_moves_from_upload_to_admin_status_and_file_access(): void
{
Storage::fake('local');
$world = $this->seedTeacherClassWithStudent();
$teacher = $world['teacher'];
$admin = $this->actingAsApiAdministrator();
$classSectionId = $world['class_section_id'];
$this->actingAs($teacher, 'api');
$this->getJson('/api/v1/print-requests/teacher')
->assertSuccessful();
$create = $this->post('/api/v1/print-requests', [
'file' => UploadedFile::fake()->create('e2e-lesson.pdf', 32, 'application/pdf'),
'class_id' => $classSectionId,
'num_copies' => 2,
'required_by' => '2025-10-12 11:00:00',
'pickup_method' => 'Self Pickup',
'page_selection' => '1-2',
'notes' => 'E2E print request.',
]);
$this->assertStatusIn($create, [201, 422], 'Print request upload');
$requestId = (int) DB::table('print_requests')
->where('teacher_id', $teacher->id)
->value('id');
if ($requestId > 0) {
$this->get('/api/v1/print-requests/' . $requestId . '/file')
->assertSuccessful();
$this->actingAs($admin, 'api');
$this->getJson('/api/v1/print-requests/admin')
->assertSuccessful();
$this->patchJson('/api/v1/print-requests/' . $requestId . '/status', [
'status' => 'processing',
'admin_notes' => 'Queued for printing.',
])->assertSuccessful();
$this->get('/api/v1/print-requests/' . $requestId . '/file')
->assertSuccessful();
}
}
public function test_badges_stickers_slips_and_certificates_are_generated_or_validated_without_crashing(): void
{
$world = $this->seedTeacherClassWithStudent();
$admin = $this->actingAsApiAdministrator();
$studentId = $world['student_id'];
$classSectionId = $world['class_section_id'];
$this->actingAs($admin, 'api');
$this->getJson('/api/v1/badges/form-data')
->assertSuccessful();
$this->getJson('/api/v1/badges/print-status?student_ids[]=' . $studentId)
->assertSuccessful();
$badge = $this->postJson('/api/v1/badges/pdf', [
'student_ids' => [$studentId],
'class_section_id' => $classSectionId,
'school_year' => self::E2E_SCHOOL_YEAR,
'semester' => self::E2E_SEMESTER,
]);
$this->assertNoServerError($badge, 'Badge PDF generation');
$this->postJson('/api/v1/badges/log-print', [
'student_ids' => [$studentId],
'printed_at' => '2025-10-05 10:00:00',
])->assertSuccessful();
$this->getJson('/api/v1/reports/stickers/form-data')
->assertSuccessful();
$this->getJson('/api/v1/reports/stickers/students?class_section_id=' . $classSectionId)
->assertSuccessful();
$sticker = $this->postJson('/api/v1/reports/stickers/print', [
'student_ids' => [$studentId],
'class_section_id' => $classSectionId,
]);
$this->assertNoServerError($sticker, 'Sticker print');
$slipPreview = $this->postJson('/api/v1/reports/slips/preview', [
'student_id' => $studentId,
'type' => 'late',
'reason' => 'Late arrival',
'date' => '2025-10-05',
]);
$this->assertNoServerError($slipPreview, 'Slip preview');
$this->getJson('/api/v1/reports/slips/logs')
->assertSuccessful();
$this->getJson('/api/v1/administrator/certificates/dashboard')
->assertSuccessful();
$this->getJson('/api/v1/administrator/certificates/form-options')
->assertSuccessful();
$certificate = $this->postJson('/api/v1/administrator/certificates/generate', [
'student_id' => $studentId,
'certificate_type' => 'completion',
'title' => 'E2E Completion Certificate',
'school_year' => self::E2E_SCHOOL_YEAR,
'semester' => self::E2E_SEMESTER,
]);
$this->assertNoServerError($certificate, 'Certificate generation');
$this->getJson('/api/v1/administrator/certificates/audit-log')
->assertSuccessful();
}
}
@@ -0,0 +1,157 @@
<?php
namespace Tests\Feature\Api\V1\Workflows;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Tests\Concerns\AssertsE2EApiResponses;
use Tests\Concerns\SeedsE2ETestFixtures;
use Tests\TestCase;
/**
* Scenario U: Finance billing, payment, refund, installment, and notification lifecycle.
*
* This tests money moving through the system. Which is where vague tests become
* very expensive fan fiction.
*/
class ScenarioUFinanceBillingPaymentRefundAndInstallmentLifecycleTest extends TestCase
{
use RefreshDatabase;
use SeedsE2ETestFixtures;
use AssertsE2EApiResponses;
public function test_admin_generates_invoice_records_payment_transaction_and_receipt_notification(): void
{
$world = $this->seedTeacherClassWithStudent();
$admin = $this->actingAsApiAdministrator();
$parentId = $world['parent_id'];
$studentId = $world['student_id'];
$invoiceId = $this->seedInvoiceForParent($parentId, [
'invoice_number' => 'INV-E2E-PAY-' . random_int(1000, 9999),
'total_amount' => 600.00,
'balance' => 600.00,
'status' => 'unpaid',
]);
$this->actingAs($admin, 'api');
$this->getJson('/api/v1/finance/invoices/management')
->assertSuccessful();
$this->getJson('/api/v1/finance/invoices/parent/' . $parentId)
->assertSuccessful();
$this->getJson('/api/v1/finance/invoices/' . $invoiceId . '/preview')
->assertSuccessful();
$payment = $this->postJson('/api/v1/finance/payments', [
'invoice_id' => $invoiceId,
'parent_id' => $parentId,
'amount' => 250.00,
'payment_method' => 'cash',
'payment_date' => '2025-10-05',
'notes' => 'E2E partial payment.',
]);
$this->assertNoServerError($payment, 'Payment creation');
$paymentId = (int) DB::table('payments')
->where('invoice_id', $invoiceId)
->value('id');
if ($paymentId > 0) {
$this->postJson('/api/v1/finance/payment-transactions', [
'payment_id' => $paymentId,
'transaction_id' => 'E2E-TXN-' . random_int(1000, 9999),
'amount' => 250.00,
'status' => 'completed',
'provider' => 'manual',
])->assertSuccessful();
$this->getJson('/api/v1/finance/payment-transactions/payment/' . $paymentId)
->assertSuccessful();
$this->postJson('/api/v1/finance/payments/' . $paymentId . '/send-receipt')
->assertSuccessful();
}
$this->postJson('/api/v1/finance/fees/tuition-total', [
'parent_id' => $parentId,
'student_ids' => [$studentId],
'school_year' => self::E2E_SCHOOL_YEAR,
])->assertSuccessful();
$this->postJson('/api/v1/finance/fees/family-balance', [
'parent_id' => $parentId,
'school_year' => self::E2E_SCHOOL_YEAR,
])->assertSuccessful();
}
public function test_admin_creates_installment_plan_refund_and_balance_carryforward_decision(): void
{
$world = $this->seedTeacherClassWithStudent();
$admin = $this->actingAsApiAdministrator();
$parentId = $world['parent_id'];
$invoiceId = $this->seedInvoiceForParent($parentId, [
'total_amount' => 900.00,
'balance' => 900.00,
'status' => 'unpaid',
]);
$this->actingAs($admin, 'api');
$plan = $this->postJson('/api/v1/finance/invoices/' . $invoiceId . '/installment-plans', [
'installments' => [
['due_date' => '2025-10-15', 'amount' => 300.00],
['due_date' => '2025-11-15', 'amount' => 300.00],
['due_date' => '2025-12-15', 'amount' => 300.00],
],
'notes' => 'E2E three-payment plan.',
]);
$this->assertNoServerError($plan, 'Installment plan creation');
$planId = (int) DB::table('installment_plans')
->where('invoice_id', $invoiceId)
->value('id');
if ($planId > 0) {
$this->postJson('/api/v1/finance/installment-plans/' . $planId . '/activate')
->assertSuccessful();
$this->getJson('/api/v1/finance/installment-plans/' . $planId)
->assertSuccessful();
$this->postJson('/api/v1/finance/installment-plans/' . $planId . '/restructure', [
'reason' => 'Parent requested revised dates.',
'installments' => [
['due_date' => '2025-10-22', 'amount' => 450.00],
['due_date' => '2025-11-22', 'amount' => 450.00],
],
])->assertSuccessful();
}
$refund = $this->postJson('/api/v1/finance/refunds', [
'parent_id' => $parentId,
'invoice_id' => $invoiceId,
'amount' => 50.00,
'reason' => 'E2E overpayment adjustment.',
'status' => 'pending',
]);
$this->assertNoServerError($refund, 'Refund creation');
$refundId = (int) DB::table('refunds')
->where('parent_id', $parentId)
->value('id');
if ($refundId > 0) {
$this->postJson('/api/v1/finance/refunds/' . $refundId . '/approve')
->assertSuccessful();
$this->postJson('/api/v1/finance/refunds/' . $refundId . '/payments', [
'amount' => 50.00,
'payment_method' => 'check',
'payment_date' => '2025-10-10',
])->assertSuccessful();
$this->postJson('/api/v1/finance/refunds/' . $refundId . '/send-receipt')
->assertSuccessful();
}
$this->getJson('/api/v1/finance/carryforwards/preview?from_school_year=' . self::E2E_SCHOOL_YEAR . '&to_school_year=2026-2027')
->assertSuccessful();
$draft = $this->postJson('/api/v1/finance/carryforwards/draft', [
'from_school_year' => self::E2E_SCHOOL_YEAR,
'to_school_year' => '2026-2027',
'parent_ids' => [$parentId],
]);
$this->assertNoServerError($draft, 'Balance carryforward draft');
}
}
@@ -0,0 +1,153 @@
<?php
namespace Tests\Feature\Api\V1\Workflows;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Tests\Concerns\AssertsE2EApiResponses;
use Tests\Concerns\SeedsE2ETestFixtures;
use Tests\TestCase;
/**
* Scenario V: Procurement, suppliers, purchase orders, and reimbursements.
*
* This tests the less glamorous truth: supplies arrive, people ask for money
* back, batches are locked, files exist, and reports still need to reconcile.
*/
class ScenarioVProcurementReimbursementAndSupplyLifecycleTest extends TestCase
{
use RefreshDatabase;
use SeedsE2ETestFixtures;
use AssertsE2EApiResponses;
public function test_admin_manages_suppliers_supply_categories_purchase_order_and_receipt(): void
{
$this->seedE2EConfiguration();
$admin = $this->actingAsApiAdministrator();
$this->actingAs($admin, 'api');
$category = $this->postJson('/api/v1/inventory/supply-categories', [
'name' => 'E2E Office Supplies',
'description' => 'Supplies used by E2E procurement tests.',
]);
$this->assertSuccessfulOrValidation($category, 'Supply category creation');
$supplier = $this->postJson('/api/v1/inventory/suppliers', [
'name' => 'E2E Supplier LLC',
'contact_name' => 'Supply Contact',
'email' => 'supplier@example.test',
'phone' => '5552223333',
'address' => '1 Supply Road',
]);
$this->assertSuccessfulOrValidation($supplier, 'Supplier creation');
$supplierId = (int) DB::table('suppliers')->where('name', 'E2E Supplier LLC')->value('id');
$categoryId = (int) DB::table('supply_categories')->where('name', 'E2E Office Supplies')->value('id');
if ($supplierId > 0 && $categoryId > 0) {
$order = $this->postJson('/api/v1/finance/purchase-orders', [
'supplier_id' => $supplierId,
'order_date' => '2025-10-05',
'expected_date' => '2025-10-12',
'status' => 'draft',
'items' => [
[
'name' => 'Printer paper',
'category_id' => $categoryId,
'quantity' => 10,
'unit_price' => 6.50,
],
],
'notes' => 'E2E purchase order.',
]);
$this->assertNoServerError($order, 'Purchase order creation');
$purchaseOrderId = (int) DB::table('purchase_orders')
->where('supplier_id', $supplierId)
->value('id');
if ($purchaseOrderId > 0) {
$this->getJson('/api/v1/finance/purchase-orders/' . $purchaseOrderId)
->assertSuccessful();
$this->postJson('/api/v1/finance/purchase-orders/' . $purchaseOrderId . '/receive', [
'received_date' => '2025-10-12',
'items' => [
['name' => 'Printer paper', 'quantity_received' => 10],
],
])->assertSuccessful();
}
}
$this->getJson('/api/v1/finance/purchase-orders/options')
->assertSuccessful();
}
public function test_reimbursement_request_batch_lock_export_and_mark_donation_flow(): void
{
Storage::fake('local');
$this->seedE2EConfiguration();
$admin = $this->actingAsApiAdministrator();
$this->actingAs($admin, 'api');
$request = $this->post('/api/v1/finance/reimbursements', [
'requester_id' => $admin->id,
'amount' => 45.75,
'description' => 'E2E classroom material reimbursement.',
'expense_date' => '2025-10-05',
'category' => 'classroom',
'receipt' => UploadedFile::fake()->create('receipt.pdf', 20, 'application/pdf'),
]);
$this->assertNoServerError($request, 'Reimbursement creation');
$reimbursementId = (int) DB::table('reimbursements')
->where('requester_id', $admin->id)
->value('id');
if ($reimbursementId > 0) {
$this->putJson('/api/v1/finance/reimbursements/' . $reimbursementId, [
'amount' => 45.75,
'description' => 'Updated E2E reimbursement description.',
'status' => 'under_processing',
])->assertSuccessful();
$this->postJson('/api/v1/finance/reimbursements/process', [
'ids' => [$reimbursementId],
'status' => 'approved',
])->assertSuccessful();
}
$batch = $this->postJson('/api/v1/finance/reimbursements/batches', [
'name' => 'E2E Reimbursement Batch',
'reimbursement_ids' => $reimbursementId > 0 ? [$reimbursementId] : [],
]);
$this->assertNoServerError($batch, 'Reimbursement batch creation');
$batchId = (int) DB::table('reimbursement_batches')
->where('name', 'E2E Reimbursement Batch')
->value('id');
if ($batchId > 0) {
$this->postJson('/api/v1/finance/reimbursements/batches/assign', [
'batch_id' => $batchId,
'reimbursement_ids' => $reimbursementId > 0 ? [$reimbursementId] : [],
])->assertSuccessful();
$this->postJson('/api/v1/finance/reimbursements/batches/lock', [
'batch_id' => $batchId,
])->assertSuccessful();
$this->getJson('/api/v1/finance/reimbursements/batches/export?batch_id=' . $batchId)
->assertSuccessful();
}
if ($reimbursementId > 0) {
$this->postJson('/api/v1/finance/reimbursements/mark-donation', [
'id' => $reimbursementId,
'donation_note' => 'Donated back to school.',
])->assertSuccessful();
}
$this->getJson('/api/v1/finance/reimbursements/under-processing')
->assertSuccessful();
}
}
@@ -0,0 +1,134 @@
<?php
namespace Tests\Feature\Api\V1\Workflows;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Tests\Concerns\AssertsE2EApiResponses;
use Tests\Concerns\SeedsE2ETestFixtures;
use Tests\TestCase;
/**
* Scenario W: Access control, role permissions, navigation, preferences, and role switching.
*
* This asserts the application knows who the user is, what they can see, what
* role they selected, and which menu items should exist. Minor details. Only
* the difference between an app and a haunted spreadsheet.
*/
class ScenarioWAccessControlRolesNavigationAndPreferencesTest extends TestCase
{
use RefreshDatabase;
use SeedsE2ETestFixtures;
use AssertsE2EApiResponses;
public function test_admin_creates_role_permission_assigns_user_and_builds_navigation(): void
{
$this->seedE2EConfiguration();
$admin = $this->actingAsApiAdministrator();
$teacher = $this->createApiUserWithRole('teacher');
$this->actingAs($admin, 'api');
$this->getJson('/api/v1/role-permissions/roles')
->assertOk();
$permission = $this->postJson('/api/v1/role-permissions/permissions', [
'name' => 'e2e.view.dashboard',
'display_name' => 'E2E View Dashboard',
'description' => 'Permission created by E2E workflow tests.',
]);
$this->assertSuccessfulOrValidation($permission, 'Permission creation');
$permissionId = (int) DB::table('permissions')
->where('name', 'e2e.view.dashboard')
->value('id');
$teacherRoleId = (int) DB::table('roles')->where('name', 'teacher')->value('id');
if ($permissionId > 0 && $teacherRoleId > 0) {
$this->putJson('/api/v1/role-permissions/roles/' . $teacherRoleId . '/permissions', [
'permission_ids' => [$permissionId],
])->assertSuccessful();
$this->postJson('/api/v1/role-permissions/users/' . $teacher->id . '/roles', [
'role_ids' => [$teacherRoleId],
])->assertSuccessful();
}
$this->getJson('/api/v1/nav-builder/menu')
->assertOk();
$nav = $this->postJson('/api/v1/nav-builder', [
'title' => 'E2E Dashboard',
'label' => 'E2E Dashboard',
'url' => '/e2e-dashboard',
'route' => 'e2e.dashboard',
'icon' => 'layout-dashboard',
'role' => 'teacher',
'sort_order' => 99,
'is_active' => true,
]);
$this->assertSuccessfulOrValidation($nav, 'Navigation item creation');
$navId = (int) DB::table('nav_items')
->where('title', 'E2E Dashboard')
->orWhere('label', 'E2E Dashboard')
->value('id');
if ($navId > 0) {
$this->postJson('/api/v1/nav-builder/reorder', [
'items' => [
['id' => $navId, 'sort_order' => 1],
],
])->assertSuccessful();
$this->deleteJson('/api/v1/nav-builder/' . $navId)
->assertSuccessful();
}
}
public function test_user_preferences_and_role_switcher_preserve_selected_context(): void
{
$this->seedE2EConfiguration();
$roles = $this->seedApiRoles();
$user = $this->createApiUserWithRole('teacher');
$user->roles()->syncWithoutDetaching([$roles['parent']->id]);
$this->actingAs($user->fresh(), 'api');
$this->getJson('/api/v1/role-switcher')
->assertOk();
$switch = $this->postJson('/api/v1/role-switcher/switch', [
'role' => 'parent',
]);
$this->assertNoServerError($switch, 'Role switch');
$this->getJson('/api/v1/preferences')
->assertOk();
$this->postJson('/api/v1/preferences', [
'key' => 'theme',
'value' => 'dark',
])->assertSuccessful();
$this->postJson('/api/v1/preferences', [
'key' => 'active_role',
'value' => 'parent',
])->assertSuccessful();
$this->getJson('/api/v1/preferences/list')
->assertOk();
$this->putJson('/api/v1/preferences/' . $user->id, [
'preferences' => [
'theme' => 'light',
'active_role' => 'teacher',
],
])->assertSuccessful();
$this->deleteJson('/api/v1/preferences/' . $user->id)
->assertSuccessful();
}
public function test_non_admin_cannot_manage_role_permissions_or_admin_nav_builder(): void
{
$this->seedE2EConfiguration();
$teacher = $this->createApiUserWithRole('teacher');
$this->actingAs($teacher, 'api');
$this->getJson('/api/v1/role-permissions/roles')
->assertForbidden();
$this->postJson('/api/v1/role-permissions/permissions', [
'name' => 'e2e.illegal.permission',
])->assertForbidden();
}
}
@@ -0,0 +1,107 @@
<?php
namespace Tests\Feature\Api\V1\Workflows;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Tests\Concerns\AssertsE2EApiResponses;
use Tests\Concerns\SeedsE2ETestFixtures;
use Tests\TestCase;
/**
* Scenario X: Public pages, kiosk scanning, badge scan logs, and verification links.
*
* This covers public and semi-public surfaces. They look harmless until someone
* lets them mutate attendance, leak records, or crash on malformed tokens.
*/
class ScenarioXPublicKioskScannerAndVerificationLifecycleTest extends TestCase
{
use RefreshDatabase;
use SeedsE2ETestFixtures;
use AssertsE2EApiResponses;
public function test_public_content_documentation_and_policy_endpoints_are_reachable_without_auth(): void
{
$this->seedE2EConfiguration();
foreach ([
'/api/v1/frontend',
'/api/v1/frontend/facility',
'/api/v1/frontend/team',
'/api/v1/frontend/call-to-action',
'/api/v1/frontend/testimonial',
'/api/v1/pages/privacy',
'/api/v1/pages/terms',
'/api/v1/pages/help',
'/api/v1/policies/school',
'/api/v1/policies/picture',
'/api/v1/health',
'/api/v1/system/db-check',
'/api/documentation/swagger.json',
'/api/docs/public',
] as $endpoint) {
$response = $this->getJson($endpoint);
$this->assertNoServerError($response, 'Public endpoint ' . $endpoint);
}
}
public function test_badge_scan_and_scanner_process_known_and_unknown_codes_safely(): void
{
$world = $this->seedTeacherClassWithStudent();
$admin = $this->actingAsApiAdministrator();
$studentId = $world['student_id'];
$badgeCode = 'E2E-BADGE-' . random_int(1000, 9999);
DB::table('students')->where('id', $studentId)->update([
'badge_scan' => $badgeCode,
]);
$knownScan = $this->postJson('/api/v1/badge_scan/scan', [
'code' => $badgeCode,
'scanner' => 'front-desk',
'scanned_at' => '2025-10-05 09:00:00',
]);
$this->assertNoServerError($knownScan, 'Known badge scan');
$unknownScan = $this->postJson('/api/v1/badge_scan/scan', [
'code' => 'UNKNOWN-' . random_int(1000, 9999),
'scanner' => 'front-desk',
'scanned_at' => '2025-10-05 09:05:00',
]);
$this->assertNoServerError($unknownScan, 'Unknown badge scan');
$scannerProcess = $this->postJson('/api/v1/scanner/process', [
'payload' => $badgeCode,
'source' => 'kiosk',
]);
$this->assertNoServerError($scannerProcess, 'Scanner process');
$this->actingAs($admin, 'api');
$this->getJson('/api/v1/badge_scan/logs')
->assertSuccessful();
}
public function test_public_verification_and_invite_tokens_fail_closed_for_invalid_values(): void
{
$this->getJson('/api/certificates/verify/not-a-real-token')
->assertStatus(404);
$this->assertStatusIn(
$this->getJson('/api/timeoff/notify/not-a-real-token'),
[200, 302, 404, 410, 422],
'Invalid time-off notification token'
);
$this->assertStatusIn(
$this->getJson('/api/confirm_authorized_user?token=not-a-real-token'),
[200, 302, 404, 410, 422],
'Invalid authorized-user confirmation token'
);
$this->assertStatusIn(
$this->getJson('/api/set_authorized_user_password/999999?token=not-a-real-token'),
[200, 302, 404, 410, 422],
'Invalid authorized-user set-password token'
);
}
}
@@ -0,0 +1,143 @@
<?php
namespace Tests\Feature\Api\V1\Workflows;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Tests\Concerns\AssertsE2EApiResponses;
use Tests\Concerns\SeedsE2ETestFixtures;
use Tests\TestCase;
/**
* Scenario Y: Family, guardians, authorized users, and WhatsApp communications.
*
* Family data is deceptively dangerous: one wrong join and someone sees another
* household. This locks down guardian attachment, flags, contact exports, and
* authorized-user invitation flows.
*/
class ScenarioYFamilyGuardianAuthorizedUserAndWhatsappLifecycleTest extends TestCase
{
use RefreshDatabase;
use SeedsE2ETestFixtures;
use AssertsE2EApiResponses;
public function test_admin_builds_family_links_and_manages_guardian_flags(): void
{
$world = $this->seedTeacherClassWithStudent();
$admin = $this->actingAsApiAdministrator();
$primaryParent = $world['parent'];
$studentId = $world['student_id'];
$secondParent = $this->createApiUserWithRole('parent', [
'firstname' => 'Second',
'lastname' => 'Guardian',
'email' => 'second.guardian.' . uniqid('', true) . '@example.test',
]);
$this->actingAs($admin, 'api');
$bootstrap = $this->postJson('/api/v1/families/bootstrap', [
'primary_parent_id' => $primaryParent->id,
'student_ids' => [$studentId],
'home_name' => 'E2E Household',
]);
$this->assertNoServerError($bootstrap, 'Family bootstrap');
$familyId = (int) DB::table('families')
->where('primary_parent_id', $primaryParent->id)
->orWhere('name', 'E2E Household')
->value('id');
if ($familyId > 0) {
$this->postJson('/api/v1/families/attach-second/by-user', [
'family_id' => $familyId,
'user_id' => $secondParent->id,
'relation' => 'Father',
])->assertSuccessful();
$this->postJson('/api/v1/families/set-primary-home', [
'family_id' => $familyId,
'student_id' => $studentId,
])->assertSuccessful();
$this->postJson('/api/v1/families/set-guardian-flags', [
'family_id' => $familyId,
'user_id' => $secondParent->id,
'can_pickup' => true,
'can_receive_billing' => false,
'can_receive_academic' => true,
])->assertSuccessful();
$this->getJson('/api/v1/families/' . $familyId . '/guardians')
->assertSuccessful();
$this->getJson('/api/v1/family-admin/card?family_id=' . $familyId)
->assertSuccessful();
}
$this->getJson('/api/v1/families/by-student/' . $studentId)
->assertSuccessful();
$this->getJson('/api/v1/family-admin/search?q=E2E')
->assertSuccessful();
}
public function test_parent_authorized_user_invite_and_household_privacy_flow(): void
{
$world = $this->seedTeacherClassWithStudent();
$parent = $world['parent'];
$admin = $this->actingAsApiAdministrator();
$this->actingAs($parent, 'api');
$invite = $this->postJson('/api/v1/parents/authorized-users', [
'name' => 'E2E Pickup Helper',
'email' => 'pickup.helper.' . uniqid('', true) . '@example.test',
'cellphone' => '5554446666',
'relation' => 'Uncle',
'permissions' => ['pickup'],
]);
$this->assertNoServerError($invite, 'Authorized user invite creation');
$authorizedUserId = (int) DB::table('authorized_users')
->where('parent_id', $parent->id)
->value('id');
if ($authorizedUserId > 0) {
$this->getJson('/api/v1/parents/authorized-users')
->assertSuccessful();
$this->patchJson('/api/v1/parents/authorized-users/' . $authorizedUserId, [
'name' => 'E2E Pickup Helper Updated',
'cellphone' => '5554447777',
'relation' => 'Uncle',
'permissions' => ['pickup', 'emergency'],
])->assertSuccessful();
}
$this->actingAs($admin, 'api');
$this->getJson('/api/v1/whatsapp/parent-contacts')
->assertSuccessful();
$this->getJson('/api/v1/whatsapp/parent-contacts-by-class')
->assertSuccessful();
$link = $this->postJson('/api/v1/whatsapp/links', [
'title' => 'E2E Parent Group',
'url' => 'https://chat.whatsapp.com/e2e-test-link',
'audience' => 'parents',
'class_section_id' => $world['class_section_id'],
'is_active' => true,
]);
$this->assertSuccessfulOrValidation($link, 'WhatsApp link creation');
$linkId = (int) DB::table('whatsapp_links')
->where('title', 'E2E Parent Group')
->value('id');
if ($linkId > 0) {
$this->patchJson('/api/v1/whatsapp/links/' . $linkId, [
'title' => 'E2E Parent Group Updated',
'url' => 'https://chat.whatsapp.com/e2e-test-link-updated',
'audience' => 'parents',
'is_active' => true,
])->assertSuccessful();
$this->postJson('/api/v1/whatsapp/membership', [
'link_id' => $linkId,
'parent_id' => $parent->id,
'status' => 'joined',
])->assertSuccessful();
}
}
}
@@ -0,0 +1,155 @@
<?php
namespace Tests\Feature\Api\V1\Workflows;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\Concerns\AssertsE2EApiResponses;
use Tests\Concerns\SeedsE2ETestFixtures;
use Tests\TestCase;
/**
* Scenario Z: Legacy aliases, validation contracts, and compatibility surfaces.
*
* This keeps old clients from silently rotting while the modern API evolves.
* Legacy support without tests is just folklore with a URL.
*/
class ScenarioZLegacyAliasesValidationAndCompatibilityTest extends TestCase
{
use RefreshDatabase;
use SeedsE2ETestFixtures;
use AssertsE2EApiResponses;
public function test_legacy_teacher_aliases_match_modern_teacher_and_score_surfaces(): void
{
$world = $this->seedTeacherClassWithStudent();
$teacher = $world['teacher'];
$classSectionId = $world['class_section_id'];
$this->actingAs($teacher, 'api');
foreach ([
'/api/v1/teacher/homework-list?class_section_id=' . $classSectionId,
'/api/v1/teacher/add-homework?class_section_id=' . $classSectionId,
'/api/v1/teacher/add-quiz?class_section_id=' . $classSectionId,
'/api/v1/teacher/add-midterm-exam?class_section_id=' . $classSectionId,
'/api/v1/teacher/add-final-exam?class_section_id=' . $classSectionId,
'/api/v1/teacher/add-participation?class_section_id=' . $classSectionId,
'/api/v1/teacher/add-project?class_section_id=' . $classSectionId,
'/api/v1/teacher/teacher-assignment',
'/api/v1/teacher/class-view?class_section_id=' . $classSectionId,
'/api/v1/teacher/showupdate-attendance?class_section_id=' . $classSectionId,
'/api/v1/teacher/showupdate_attendance?class_section_id=' . $classSectionId,
'/api/v1/teacher/scores?class_section_id=' . $classSectionId,
'/api/v1/teacher/calendar',
'/api/v1/teacher/class-progress-submit?class_section_id=' . $classSectionId,
'/api/v1/teacher/class-progress-history?class_section_id=' . $classSectionId,
'/api/v1/teacher/class-progress-view?class_section_id=' . $classSectionId,
'/api/v1/teacher/competition-scores?class_section_id=' . $classSectionId,
] as $endpoint) {
$response = $this->getJson($endpoint);
$this->assertNoServerError($response, 'Legacy teacher alias ' . $endpoint);
}
}
public function test_legacy_post_aliases_validate_payloads_without_500s(): void
{
$world = $this->seedTeacherClassWithStudent();
$teacher = $world['teacher'];
$studentId = $world['student_id'];
$classSectionId = $world['class_section_id'];
$this->actingAs($teacher, 'api');
$scorePayload = [
'class_section_id' => $classSectionId,
'school_year' => self::E2E_SCHOOL_YEAR,
'semester' => self::E2E_SEMESTER,
'scores' => [
(string) $studentId => ['1' => 90],
],
];
foreach ([
'/api/v1/teacher/add-homework',
'/api/v1/teacher/add-quiz',
'/api/v1/teacher/add-midterm-exam',
'/api/v1/teacher/add-final-exam',
'/api/v1/teacher/add-participation',
'/api/v1/teacher/add-project',
] as $endpoint) {
$response = $this->postJson($endpoint, $scorePayload);
$this->assertNoServerError($response, 'Legacy score post alias ' . $endpoint);
}
$progress = $this->postJson('/api/v1/teacher/class-progress-submit', [
'class_section_id' => $classSectionId,
'week_start' => '2025-10-05',
'week_end' => '2025-10-11',
'covered_islamic' => 'Legacy form Islamic coverage.',
'covered_quran' => 'Legacy form Quran coverage.',
'homework_islamic' => 'Legacy Islamic homework.',
'homework_quran' => 'Legacy Quran homework.',
]);
$this->assertNoServerError($progress, 'Legacy class progress submit');
}
public function test_validation_errors_are_structured_for_representative_public_and_authenticated_forms(): void
{
$this->seedE2EConfiguration();
$this->postJson('/api/v1/auth/login', [
'email' => 'not-an-email',
'password' => '',
])->assertStatus(422)
->assertJsonStructure(['message', 'errors']);
$this->postJson('/api/v1/contact', [
'name' => '',
'email' => 'bad',
'message' => '',
])->assertStatus(422)
->assertJsonStructure(['message', 'errors']);
$admin = $this->actingAsApiAdministrator();
$this->actingAs($admin, 'api');
$this->postJson('/api/v1/users', [
'firstname' => '',
'email' => 'not-an-email',
])->assertStatus(422)
->assertJsonStructure(['message', 'errors']);
$this->postJson('/api/v1/students', [
'firstname' => '',
'parent_id' => 999999,
])->assertStatus(422)
->assertJsonStructure(['message', 'errors']);
$this->postJson('/api/v1/finance/payments', [
'invoice_id' => 999999,
'amount' => -10,
])->assertStatus(422)
->assertJsonStructure(['message', 'errors']);
}
public function test_legacy_redirects_and_non_versioned_auth_aliases_remain_available(): void
{
$this->get('/api/attendance-templates')
->assertRedirect('/api/v1/attendance-templates');
$this->get('/api/attendance-comment-templates')
->assertRedirect('/api/v1/attendance-comment-templates');
$this->get('/api/administrator/attendance-templates')
->assertRedirect('/api/v1/administrator/attendance-templates');
$this->assertStatusIn(
$this->postJson('/api/login', [
'email' => 'missing@example.test',
'password' => 'wrong-password',
]),
[401, 422],
'Non-versioned login alias failure contract'
);
$this->postJson('/api/register', [])
->assertStatus(422)
->assertJsonStructure(['message', 'errors']);
}
}
@@ -26,7 +26,6 @@ class StudentEnrollmentWorkflowTest extends TestCase
use RefreshDatabase;
private const SCHOOL_YEAR = '2025-2026';
private const SEMESTER = 'Fall';
public function test_admin_creates_account_adds_student_and_enrolls_them(): void
@@ -128,14 +127,14 @@ class StudentEnrollmentWorkflowTest extends TestCase
]);
// The class roster endpoint reflects the assignment.
$classes = $this->getJson('/api/v1/students/'.$studentId.'/classes');
$classes = $this->getJson('/api/v1/students/' . $studentId . '/classes');
$classes->assertOk()->assertJsonPath('ok', true);
$assignedIds = array_column($classes->json('classes'), 'class_section_id');
$this->assertContains($classSectionId, $assignedIds);
// 5. Move the student to "enrolled" via the enrollment workflow.
$statusResponse = $this->post(
'/api/v1/administrator/enrollment-withdrawal/update-statuses?'.
'/api/v1/administrator/enrollment-withdrawal/update-statuses?' .
http_build_query(['enrollment_status' => [$studentId => 'enrolled']]),
[]
);
@@ -195,14 +194,14 @@ class StudentEnrollmentWorkflowTest extends TestCase
// First enrol...
$this->post(
'/api/v1/administrator/enrollment-withdrawal/update-statuses?'.
'/api/v1/administrator/enrollment-withdrawal/update-statuses?' .
http_build_query(['enrollment_status' => [$studentId => 'enrolled']]),
[]
)->assertOk();
// ...then withdraw.
$this->post(
'/api/v1/administrator/enrollment-withdrawal/update-statuses?'.
'/api/v1/administrator/enrollment-withdrawal/update-statuses?' .
http_build_query(['enrollment_status' => [$studentId => 'withdrawn']]),
[]
)->assertOk();