update tests

This commit is contained in:
root
2026-06-08 22:06:30 -04:00
parent 79024235ef
commit 60ecacb7f8
54 changed files with 13243 additions and 5561 deletions
@@ -13,6 +13,54 @@ class ApiAuthenticationAndAuthorizationTest extends TestCase
use CreatesApiTestUsers;
use RefreshDatabase;
public function test_api_login_returns_token_and_user_object(): void
{
$user = $this->createApiUserWithRole('teacher', [
'firstname' => 'Login',
'lastname' => 'Tester',
'email' => 'api.login@example.test',
]);
$this->postJson('/api/v1/auth/login', [
'email' => strtoupper($user->email),
'password' => 'password',
])
->assertOk()
->assertJsonPath('status', true)
->assertJsonStructure([
'token',
'access_token',
'token_type',
'expires_in',
'user' => ['id', 'name', 'firstname', 'lastname', 'email', 'roles'],
])
->assertJsonPath('user.email', 'api.login@example.test');
}
public function test_session_login_also_returns_token_and_user_object_for_spa_clients(): void
{
$user = $this->createApiUserWithRole('teacher', [
'firstname' => 'Session',
'lastname' => 'Tester',
'email' => 'session.login@example.test',
]);
$this->postJson('/user/login', [
'email' => $user->email,
'password' => 'password',
])
->assertOk()
->assertJsonPath('status', true)
->assertJsonStructure([
'token',
'access_token',
'user' => ['id', 'name', 'firstname', 'lastname', 'email', 'roles'],
'next_url',
])
->assertJsonPath('user.email', 'session.login@example.test');
}
public function test_auth_me_requires_authentication(): void
{
$this->getJson('/api/v1/auth/me')->assertUnauthorized();
@@ -0,0 +1,70 @@
<?php
namespace Tests\Feature\Api\V1\Administrator;
use App\Services\Administrator\AdministratorDashboardService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Mockery;
use Tests\Concerns\CreatesApiTestUsers;
use Tests\TestCase;
class AdministratorDashboardControllerTest extends TestCase
{
use CreatesApiTestUsers;
use RefreshDatabase;
protected function tearDown(): void
{
Mockery::close();
parent::tearDown();
}
public function test_metrics_require_authentication(): void
{
$this->getJson('/api/v1/administrator/dashboard/metrics')
->assertStatus(401);
}
public function test_metrics_are_forbidden_for_non_admin_users(): void
{
$teacher = $this->createApiUserWithRole('teacher');
$this->actingAs($teacher, 'api')
->getJson('/api/v1/administrator/dashboard/metrics')
->assertStatus(403);
}
public function test_metrics_return_payload_for_admin(): void
{
$admin = $this->createApiUserWithRole('administrator');
$mock = Mockery::mock(AdministratorDashboardService::class);
$mock->shouldReceive('metrics')->once()->andReturn([
'students' => 42,
'teachers' => 5,
]);
$this->app->instance(AdministratorDashboardService::class, $mock);
$this->actingAs($admin, 'api')
->getJson('/api/v1/administrator/dashboard/metrics')
->assertOk()
->assertJson(['students' => 42, 'teachers' => 5]);
}
public function test_user_search_passes_query_to_service(): void
{
$admin = $this->createApiUserWithRole('administrator');
$mock = Mockery::mock(AdministratorDashboardService::class);
$mock->shouldReceive('userSearch')
->once()
->with('smith')
->andReturn(['results' => [['id' => 1, 'name' => 'Smith']]]);
$this->app->instance(AdministratorDashboardService::class, $mock);
$this->actingAs($admin, 'api')
->getJson('/api/v1/administrator/dashboard/user-search?query=smith')
->assertOk()
->assertJsonPath('results.0.name', 'Smith');
}
}
@@ -0,0 +1,89 @@
<?php
namespace Tests\Feature\Api\V1\Administrator;
use App\Services\Administrator\Trophy\TrophyReportService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Mockery;
use Tests\Concerns\CreatesApiTestUsers;
use Tests\TestCase;
class TrophyControllerTest extends TestCase
{
use CreatesApiTestUsers;
use RefreshDatabase;
protected function tearDown(): void
{
Mockery::close();
parent::tearDown();
}
public function test_index_requires_authentication(): void
{
$this->getJson('/api/v1/administrator/trophy')
->assertStatus(401);
}
public function test_index_is_forbidden_for_non_admin_users(): void
{
$teacher = $this->createApiUserWithRole('teacher');
$this->actingAs($teacher, 'api')
->getJson('/api/v1/administrator/trophy')
->assertStatus(403);
}
public function test_index_returns_projection_for_admin(): void
{
$admin = $this->createApiUserWithRole('administrator');
$mock = Mockery::mock(TrophyReportService::class);
$mock->shouldReceive('projection')->once()->andReturn(['rows' => []]);
$this->app->instance(TrophyReportService::class, $mock);
$this->actingAs($admin, 'api')
->getJson('/api/v1/administrator/trophy')
->assertOk()
->assertJsonPath('ok', true)
->assertJsonPath('data.rows', []);
}
public function test_winners_returns_data_for_admin(): void
{
$admin = $this->createApiUserWithRole('administrator');
$mock = Mockery::mock(TrophyReportService::class);
$mock->shouldReceive('winners')->once()->andReturn(['winners' => []]);
$this->app->instance(TrophyReportService::class, $mock);
$this->actingAs($admin, 'api')
->getJson('/api/v1/administrator/trophy/winners')
->assertOk()
->assertJsonPath('ok', true);
}
public function test_final_returns_data_for_admin(): void
{
$admin = $this->createApiUserWithRole('administrator');
$mock = Mockery::mock(TrophyReportService::class);
$mock->shouldReceive('final')->once()->andReturn(['final' => []]);
$this->app->instance(TrophyReportService::class, $mock);
$this->actingAs($admin, 'api')
->getJson('/api/v1/administrator/trophy/final')
->assertOk()
->assertJsonPath('ok', true);
}
public function test_index_validates_percentile_range(): void
{
$admin = $this->createApiUserWithRole('administrator');
$this->actingAs($admin, 'api')
->getJson('/api/v1/administrator/trophy?percentile=150')
->assertStatus(422)
->assertJsonValidationErrors(['percentile']);
}
}
@@ -0,0 +1,53 @@
<?php
namespace Tests\Feature\Api\V1\BadgeScan;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\Concerns\CreatesApiTestUsers;
use Tests\TestCase;
class BadgeScanControllerTest extends TestCase
{
use CreatesApiTestUsers;
use RefreshDatabase;
public function test_scan_is_public_but_validates_badge_value(): void
{
$this->postJson('/api/v1/badge_scan/scan', [])
->assertStatus(422)
->assertJsonPath('status', false);
}
public function test_scan_returns_404_for_unrecognized_badge(): void
{
$this->postJson('/api/v1/badge_scan/scan', ['badge_scan' => 'UNKNOWN-BADGE-123'])
->assertNotFound()
->assertJsonPath('status', false);
}
public function test_logs_require_authentication(): void
{
$this->getJson('/api/v1/badge_scan/logs')
->assertStatus(401);
}
public function test_logs_are_forbidden_for_non_staff_roles(): void
{
$parent = $this->createApiUserWithRole('parent');
$this->actingAs($parent, 'api')
->getJson('/api/v1/badge_scan/logs')
->assertStatus(403);
}
public function test_logs_return_data_for_staff(): void
{
$admin = $this->createApiUserWithRole('administrator');
$this->actingAs($admin, 'api')
->getJson('/api/v1/badge_scan/logs')
->assertOk()
->assertJsonPath('status', true)
->assertJsonStructure(['status', 'data' => ['logs', 'meta']]);
}
}
@@ -0,0 +1,118 @@
<?php
namespace Tests\Feature\Api\V1\Finance;
use App\Models\EventCharge;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\Concerns\CreatesApiTestUsers;
use Tests\TestCase;
class EventChargeControllerTest extends TestCase
{
use CreatesApiTestUsers;
use RefreshDatabase;
private function makeCharge(array $overrides = []): EventCharge
{
return EventCharge::query()->create(array_merge([
'event_name' => 'Field Trip',
'description' => 'Annual field trip charge',
'amount' => 25.00,
'charged' => 0,
'semester' => 'Fall',
'school_year' => '2025-2026',
], $overrides));
}
public function test_show_requires_authentication(): void
{
$charge = $this->makeCharge();
$this->getJson('/api/v1/finance/event-charges/' . $charge->id)
->assertStatus(401);
}
public function test_show_returns_event_charge(): void
{
$this->actingAsApiAdministrator();
$charge = $this->makeCharge();
$this->getJson('/api/v1/finance/event-charges/' . $charge->id)
->assertOk()
->assertJsonPath('ok', true)
->assertJsonPath('event_charge.id', $charge->id)
->assertJsonPath('event_charge.event_name', 'Field Trip');
}
public function test_show_returns_404_for_unknown_charge(): void
{
$this->actingAsApiAdministrator();
$this->getJson('/api/v1/finance/event-charges/999999')
->assertNotFound();
}
public function test_update_modifies_event_charge(): void
{
$this->actingAsApiAdministrator();
$charge = $this->makeCharge();
$this->putJson('/api/v1/finance/event-charges/' . $charge->id, [
'event_name' => 'Museum Visit',
'amount' => 40.50,
])
->assertOk()
->assertJsonPath('ok', true)
->assertJsonPath('event_charge.event_name', 'Museum Visit');
$this->assertDatabaseHas('event_charges', [
'id' => $charge->id,
'event_name' => 'Museum Visit',
]);
}
public function test_approve_marks_charge_as_charged(): void
{
$this->actingAsApiAdministrator();
$charge = $this->makeCharge(['charged' => 0]);
$this->postJson('/api/v1/finance/event-charges/' . $charge->id . '/approve')
->assertOk()
->assertJsonPath('ok', true);
$this->assertDatabaseHas('event_charges', [
'id' => $charge->id,
'charged' => 1,
]);
}
public function test_void_clears_charged_flag(): void
{
$this->actingAsApiAdministrator();
$charge = $this->makeCharge(['charged' => 1]);
$this->postJson('/api/v1/finance/event-charges/' . $charge->id . '/void')
->assertOk()
->assertJsonPath('ok', true);
$this->assertDatabaseHas('event_charges', [
'id' => $charge->id,
'charged' => 0,
]);
}
public function test_destroy_voids_the_charge(): void
{
$this->actingAsApiAdministrator();
$charge = $this->makeCharge(['charged' => 1]);
$this->deleteJson('/api/v1/finance/event-charges/' . $charge->id)
->assertOk()
->assertJsonPath('ok', true);
$this->assertDatabaseHas('event_charges', [
'id' => $charge->id,
'charged' => 0,
]);
}
}
@@ -0,0 +1,106 @@
<?php
namespace Tests\Feature\Api\V1\Parents;
use App\Models\AuthorizedUser;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\Concerns\CreatesApiTestUsers;
use Tests\TestCase;
class AuthorizedUsersControllerTest extends TestCase
{
use CreatesApiTestUsers;
use RefreshDatabase;
private function makeAuthorizedUser(int $parentId, array $overrides = []): AuthorizedUser
{
return AuthorizedUser::query()->create(array_merge([
'user_id' => $parentId,
'firstname' => 'Aunt',
'lastname' => 'Carer',
'email' => 'carer-' . uniqid() . '@example.test',
'phone_number' => '5551112222',
'relation_to_user' => 'Aunt',
'status' => 'pending',
], $overrides));
}
public function test_index_requires_authentication(): void
{
$this->getJson('/api/v1/parents/authorized-users')
->assertStatus(401);
}
public function test_index_is_forbidden_for_non_parent_users(): void
{
$teacher = $this->createApiUserWithRole('teacher');
$this->actingAs($teacher, 'api')
->getJson('/api/v1/parents/authorized-users')
->assertStatus(403);
}
public function test_index_returns_only_the_parents_own_authorized_users(): void
{
$parent = $this->createApiUserWithRole('parent');
$otherParent = $this->createApiUserWithRole('parent');
$own = $this->makeAuthorizedUser($parent->id);
$this->makeAuthorizedUser($otherParent->id);
$response = $this->actingAs($parent, 'api')
->getJson('/api/v1/parents/authorized-users')
->assertOk()
->assertJsonPath('status', true);
$this->assertCount(1, $response->json('data'));
$this->assertSame($own->id, $response->json('data.0.id'));
}
public function test_show_returns_404_for_unknown_authorized_user(): void
{
$parent = $this->createApiUserWithRole('parent');
$this->actingAs($parent, 'api')
->getJson('/api/v1/parents/authorized-users/999999')
->assertNotFound()
->assertJsonPath('status', false);
}
public function test_show_returns_404_for_another_parents_authorized_user(): void
{
$parent = $this->createApiUserWithRole('parent');
$otherParent = $this->createApiUserWithRole('parent');
$foreign = $this->makeAuthorizedUser($otherParent->id);
$this->actingAs($parent, 'api')
->getJson('/api/v1/parents/authorized-users/' . $foreign->id)
->assertNotFound();
}
public function test_destroy_deletes_the_parents_authorized_user(): void
{
$parent = $this->createApiUserWithRole('parent');
$record = $this->makeAuthorizedUser($parent->id);
$this->actingAs($parent, 'api')
->deleteJson('/api/v1/parents/authorized-users/' . $record->id)
->assertOk()
->assertJsonPath('status', true);
$this->assertDatabaseMissing('authorized_users', ['id' => $record->id]);
}
public function test_parent_cannot_delete_another_parents_authorized_user(): void
{
$parent = $this->createApiUserWithRole('parent');
$otherParent = $this->createApiUserWithRole('parent');
$foreign = $this->makeAuthorizedUser($otherParent->id);
$this->actingAs($parent, 'api')
->deleteJson('/api/v1/parents/authorized-users/' . $foreign->id)
->assertNotFound();
$this->assertDatabaseHas('authorized_users', ['id' => $foreign->id]);
}
}
@@ -0,0 +1,82 @@
<?php
namespace Tests\Feature\Api\V1\Public;
use App\Models\Competition;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class PublicWinnersControllerTest extends TestCase
{
use RefreshDatabase;
public function test_competition_index_is_public_and_returns_published_competitions(): void
{
Competition::query()->create([
'title' => 'Published Quiz Bowl',
'school_year' => '2025-2026',
'is_published' => 1,
'published_at' => now(),
]);
Competition::query()->create([
'title' => 'Hidden Draft',
'school_year' => '2025-2026',
'is_published' => 0,
]);
$response = $this->getJson('/api/winners/competitions');
$response->assertOk()
->assertJsonPath('status', true)
->assertJsonStructure(['status', 'data' => ['competitions']]);
$titles = array_column($response->json('data.competitions'), 'title');
$this->assertContains('Published Quiz Bowl', $titles);
$this->assertNotContains('Hidden Draft', $titles);
}
public function test_competition_index_returns_empty_collection_when_none_published(): void
{
$this->getJson('/api/winners/competitions')
->assertOk()
->assertJsonPath('status', true)
->assertJsonPath('data.competitions', []);
}
public function test_competition_show_returns_payload_for_published_competition(): void
{
$competition = Competition::query()->create([
'title' => 'Spelling Bee',
'school_year' => '2025-2026',
'is_published' => 1,
'published_at' => now(),
]);
$this->getJson('/api/winners/competitions/' . $competition->id)
->assertOk()
->assertJsonPath('status', true)
->assertJsonPath('data.competition.id', $competition->id)
->assertJsonStructure(['data' => ['competition', 'winners_by_class', 'section_map', 'question_counts']]);
}
public function test_competition_show_returns_404_for_unknown_competition(): void
{
$this->getJson('/api/winners/competitions/999999')
->assertNotFound()
->assertJsonPath('status', false);
}
public function test_competition_show_returns_404_for_unpublished_competition(): void
{
$competition = Competition::query()->create([
'title' => 'Unpublished',
'school_year' => '2025-2026',
'is_published' => 0,
]);
$this->getJson('/api/winners/competitions/' . $competition->id)
->assertNotFound()
->assertJsonPath('status', false);
}
}
@@ -0,0 +1,42 @@
<?php
namespace Tests\Feature\Api\V1;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\Concerns\CreatesApiTestUsers;
use Tests\TestCase;
class ScannerControllerTest extends TestCase
{
use CreatesApiTestUsers;
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
$this->seedApiTestConfig();
}
public function test_process_requires_a_badge_code(): void
{
$this->postJson('/api/v1/scanner/process', ['badgeCode' => ''])
->assertStatus(400)
->assertJsonPath('status', false)
->assertJsonPath('ok', false);
}
public function test_process_returns_404_for_unknown_badge(): void
{
$this->postJson('/api/v1/scanner/process', ['badgeCode' => 'NO-SUCH-BADGE'])
->assertNotFound()
->assertJsonPath('status', false)
->assertJsonPath('ok', false);
}
public function test_process_accepts_alternative_badge_field_names(): void
{
$this->postJson('/api/v1/scanner/process', ['barcode' => 'STILL-UNKNOWN'])
->assertNotFound()
->assertJsonPath('status', false);
}
}
@@ -0,0 +1,50 @@
<?php
namespace Tests\Feature\Api\V1\System;
use App\Models\Stats;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Laravel\Sanctum\Sanctum;
use Tests\TestCase;
class StatsControllerTest extends TestCase
{
use RefreshDatabase;
public function test_stats_require_authentication(): void
{
$this->getJson('/api/v1/stats')
->assertStatus(401);
}
public function test_stats_index_returns_rows_for_authenticated_user(): void
{
Stats::query()->create([
'students' => 12,
'teachers' => 3,
'admins' => 2,
'users' => 17,
]);
Sanctum::actingAs(User::factory()->create());
$response = $this->getJson('/api/v1/stats');
$response->assertOk()
->assertJsonPath('status', true)
->assertJsonStructure(['status', 'message', 'data']);
$this->assertSame(12, $response->json('data.0.students'));
}
public function test_stats_index_returns_empty_array_when_no_stats(): void
{
Sanctum::actingAs(User::factory()->create());
$this->getJson('/api/v1/stats')
->assertOk()
->assertJsonPath('status', true)
->assertJsonPath('data', []);
}
}
@@ -0,0 +1,67 @@
<?php
namespace Tests\Feature\Api\V1\Utilities;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
use Laravel\Sanctum\Sanctum;
use Tests\TestCase;
class ProofreadControllerTest extends TestCase
{
use RefreshDatabase;
public function test_proofread_requires_authentication(): void
{
$this->postJson('/api/proofread', ['text' => 'hello'])
->assertStatus(401);
}
public function test_proofread_rejects_empty_text(): void
{
Sanctum::actingAs(User::factory()->create());
$this->postJson('/api/proofread', ['text' => ''])
->assertStatus(422)
->assertJsonPath('ok', false);
}
public function test_proofread_rejects_text_that_is_too_long(): void
{
Sanctum::actingAs(User::factory()->create());
$this->postJson('/api/proofread', ['text' => str_repeat('a', 20001)])
->assertStatus(422)
->assertJsonPath('ok', false);
}
public function test_proofread_proxies_languagetool_and_returns_result(): void
{
Http::fake([
'api.languagetool.org/*' => Http::response(['matches' => []], 200),
]);
Sanctum::actingAs(User::factory()->create());
$this->postJson('/api/proofread', ['text' => 'This are a test.'])
->assertOk()
->assertJsonPath('ok', true)
->assertJsonStructure(['ok', 'result', 'csrfHash']);
Http::assertSent(fn ($request) => str_contains($request->url(), 'languagetool.org'));
}
public function test_proofread_returns_502_when_upstream_fails(): void
{
Http::fake([
'api.languagetool.org/*' => Http::response('error', 500),
]);
Sanctum::actingAs(User::factory()->create());
$this->postJson('/api/proofread', ['text' => 'Some text to check.'])
->assertStatus(502)
->assertJsonPath('ok', false);
}
}
@@ -0,0 +1,104 @@
<?php
namespace Tests\Feature\Api\V1\Workflows;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Event;
use Tests\Concerns\SeedsE2ETestFixtures;
use Tests\TestCase;
/**
* Scenario A: New Family Registration to Active Enrollment.
*
* @see docs/alrahma_end_to_end_use_case_test_plan.md Scenario A
*/
class ScenarioAFamilyEnrollmentTest extends TestCase
{
use RefreshDatabase;
use SeedsE2ETestFixtures;
public function test_admin_onboards_family_through_active_enrollment(): void
{
Event::fake();
$this->seedE2EConfiguration();
$admin = $this->actingAsApiAdministrator();
$classSectionId = $this->seedClassSection(701, '5A', 5);
$teacher = $this->createApiUserWithRole('teacher');
$this->seedTeacherClassAssignment($teacher->id, $classSectionId);
$parentId = $this->createParentAccountViaApi([
'firstname' => 'Khadija',
'lastname' => 'Saleh',
'email' => 'khadija.saleh@example.test',
]);
$studentId = $this->createStudentViaApi($parentId, [
'firstname' => 'Yusuf',
'lastname' => 'Saleh',
'name' => 'Khadija Saleh',
'email' => 'khadija.saleh@example.test',
]);
$this->assignStudentToClassViaApi($studentId, $classSectionId)
->assertOk()
->assertJsonPath('ok', true);
$this->postEnrollmentStatuses([$studentId => 'enrolled'])->assertOk();
$this->assertDatabaseHas('enrollments', [
'student_id' => $studentId,
'parent_id' => $parentId,
'enrollment_status' => 'enrolled',
]);
// Teacher sees student in roster.
$this->actingAs($teacher, 'api');
$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');
$this->assertContains($studentId, $studentIds);
// Parent sees own profile.
$parent = User::query()->findOrFail($parentId);
$this->actingAs($parent, 'api');
$this->getJson('/api/v1/parents/profile')
->assertOk()
->assertJsonPath('ok', true);
// Admin can search the new user.
$this->actingAs($admin, 'api');
$this->getJson('/api/v1/administrator/dashboard/user-search?query=Saleh')
->assertOk();
}
public function test_parent_cannot_update_unrelated_student(): void
{
Event::fake();
$this->seedE2EConfiguration();
$this->actingAsApiAdministrator();
$parentA = $this->createApiUserWithRole('parent');
$parentB = $this->createApiUserWithRole('parent');
$studentB = $this->createStudentViaApi($parentB->id, [
'firstname' => 'Other',
'lastname' => 'Child',
]);
$this->actingAs($parentA, 'api');
$this->patchJson('/api/v1/parents/students/' . $studentB, [
'firstname' => 'Hacked',
'lastname' => 'Child',
'dob' => '2015-01-01',
'gender' => 'Male',
])->assertNotFound();
$this->assertDatabaseHas('students', [
'id' => $studentB,
'firstname' => 'Other',
]);
}
}
@@ -0,0 +1,107 @@
<?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 B: Normal Sunday School Day.
*
* @see docs/alrahma_end_to_end_use_case_test_plan.md Scenario B
*/
class ScenarioBSundaySchoolDayTest extends TestCase
{
use RefreshDatabase;
use SeedsE2ETestFixtures;
public function test_teacher_submits_attendance_and_parent_views_report(): void
{
$world = $this->seedTeacherClassWithStudent();
$teacher = $world['teacher'];
$classSectionId = $world['class_section_id'];
$studentId = $world['student_id'];
$parent = $world['parent'];
$this->actingAs($teacher, 'api');
$form = $this->getJson('/api/v1/attendance/teacher/form?class_section_id=' . $classSectionId)
->assertOk();
$date = (string) ($form->json('data.current_sunday') ?? '2025-10-05');
$submit = $this->postJson('/api/v1/attendance/teacher/submit', [
'class_section_id' => $classSectionId,
'date' => $date,
'attendance' => [
['student_id' => $studentId, 'status' => 'present'],
],
'teachers' => [
(string) $teacher->id => ['status' => 'present'],
],
]);
$submit->assertOk();
$this->assertDatabaseHas('attendance_data', [
'student_id' => $studentId,
'class_section_id' => $classSectionId,
'status' => 'present',
'school_year' => self::E2E_SCHOOL_YEAR,
]);
// Parent views attendance for their child.
$this->actingAs($parent, 'api');
$this->getJson('/api/v1/parents/attendance?school_year=' . self::E2E_SCHOOL_YEAR)
->assertOk()
->assertJsonPath('ok', true);
}
public function test_teacher_cannot_submit_attendance_for_unassigned_class(): void
{
$world = $this->seedTeacherClassWithStudent();
$otherSectionId = $this->seedClassSection(999, '9Z', 9);
$otherTeacher = $this->createApiUserWithRole('teacher');
$this->seedTeacherClassAssignment($otherTeacher->id, $otherSectionId);
$this->actingAs($world['teacher'], 'api');
$this->postJson('/api/v1/attendance/teacher/submit', [
'class_section_id' => $otherSectionId,
'date' => '2025-10-05',
'attendance' => [
['student_id' => $world['student_id'], 'status' => 'present'],
],
'teachers' => [
(string) $world['teacher']->id => ['status' => 'present'],
],
])->assertStatus(422);
$this->assertDatabaseMissing('attendance_data', [
'student_id' => $world['student_id'],
'class_section_id' => $otherSectionId,
]);
}
public function test_admin_views_daily_attendance_summary(): void
{
$world = $this->seedTeacherClassWithStudent();
$admin = $this->actingAsApiAdministrator();
DB::table('attendance_data')->insert([
'class_id' => 3,
'class_section_id' => $world['class_section_id'],
'student_id' => $world['student_id'],
'school_id' => '1',
'date' => '2025-10-05',
'status' => 'late',
'semester' => self::E2E_SEMESTER,
'school_year' => self::E2E_SCHOOL_YEAR,
'created_at' => now(),
'updated_at' => now(),
]);
$this->actingAs($admin, 'api');
$this->getJson('/api/v1/attendance/admin/daily?date=2025-10-05')
->assertOk();
}
}
@@ -0,0 +1,95 @@
<?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 C: Academic Term Completion.
*
* @see docs/alrahma_end_to_end_use_case_test_plan.md Scenario C
*/
class ScenarioCAcademicTermTest extends TestCase
{
use RefreshDatabase;
use SeedsE2ETestFixtures;
public function test_teacher_enters_scores_admin_locks_grading_and_report_card_is_generated(): void
{
$world = $this->seedTeacherClassWithStudent();
$teacher = $world['teacher'];
$classSectionId = $world['class_section_id'];
$studentId = $world['student_id'];
DB::table('homework')->insert([
'student_id' => $studentId,
'school_id' => 1,
'class_section_id' => $classSectionId,
'updated_by' => $teacher->id,
'homework_index' => 1,
'score' => 85,
'semester' => self::E2E_SEMESTER,
'school_year' => self::E2E_SCHOOL_YEAR,
'created_at' => now(),
'updated_at' => now(),
]);
DB::table('semester_scores')->insert([
'student_id' => $studentId,
'class_section_id' => $classSectionId,
'school_id' => 'E2E-SCORE-1',
'school_year' => self::E2E_SCHOOL_YEAR,
'semester' => self::E2E_SEMESTER,
'midterm_exam_score' => '85',
'participation_score' => '10',
'created_at' => now(),
'updated_at' => now(),
]);
$this->actingAs($teacher, 'api');
$this->getJson('/api/v1/scores/overview?class_section_id=' . $classSectionId . '&semester=' . self::E2E_SEMESTER . '&school_year=' . self::E2E_SCHOOL_YEAR)
->assertOk()
->assertJsonPath('ok', true);
$this->postJson('/api/v1/scores/lock', [
'class_section_id' => $classSectionId,
'semester' => self::E2E_SEMESTER,
'school_year' => self::E2E_SCHOOL_YEAR,
'missing_ok' => [],
])->assertOk()->assertJsonPath('ok', true);
$this->assertDatabaseHas('grading_locks', [
'class_section_id' => $classSectionId,
'semester' => self::E2E_SEMESTER,
'school_year' => self::E2E_SCHOOL_YEAR,
'is_locked' => 1,
]);
$this->getJson('/api/v1/reports/report-cards/students/' . $studentId . '?school_year=' . self::E2E_SCHOOL_YEAR . '&semester=' . self::E2E_SEMESTER)
->assertOk();
}
public function test_teacher_invalid_homework_score_is_rejected(): void
{
$world = $this->seedTeacherClassWithStudent();
$this->actingAs($world['teacher'], 'api');
$response = $this->postJson('/api/v1/scores/homework', [
'class_section_id' => $world['class_section_id'],
'semester' => self::E2E_SEMESTER,
'school_year' => self::E2E_SCHOOL_YEAR,
'scores' => [
$world['student_id'] => [
1 => 999,
],
],
]);
$this->assertGreaterThanOrEqual(400, $response->getStatusCode());
$this->assertNotSame(true, $response->json('ok'));
}
}
@@ -0,0 +1,92 @@
<?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 D: Family Billing Lifecycle.
*
* @see docs/alrahma_end_to_end_use_case_test_plan.md Scenario D
*/
class ScenarioDFamilyBillingTest extends TestCase
{
use RefreshDatabase;
use SeedsE2ETestFixtures;
public function test_admin_runs_invoice_discount_payment_and_reporting_workflow(): void
{
$world = $this->seedTeacherClassWithStudent();
$admin = $this->actingAsApiAdministrator();
$parentId = $world['parent_id'];
$studentId = $world['student_id'];
DB::table('enrollments')->insert([
'parent_id' => $parentId,
'student_id' => $studentId,
'school_year' => self::E2E_SCHOOL_YEAR,
'semester' => self::E2E_SEMESTER,
'enrollment_status' => 'payment pending',
'enrollment_date' => '2025-09-01',
]);
$invoiceId = $this->seedInvoiceForParent($parentId);
$voucherId = $this->seedDiscountVoucher();
$this->actingAs($admin, 'api');
$this->postJson('/api/v1/discounts/apply', [
'voucher_id' => $voucherId,
'parent_ids' => [$parentId],
'allow_additional' => true,
])->assertOk()->assertJsonPath('ok', true);
$chargeResponse = $this->postJson('/api/v1/finance/event-charges', [
'event_name' => 'Science Fair',
'description' => 'Materials fee',
'amount' => 15.00,
'parent_id' => $parentId,
'student_id' => $studentId,
'school_year' => self::E2E_SCHOOL_YEAR,
'semester' => self::E2E_SEMESTER,
]);
$chargeResponse->assertOk()->assertJsonPath('ok', true);
$chargeId = (int) DB::table('event_charges')
->where('parent_id', $parentId)
->where('student_id', $studentId)
->where('event_name', 'Science Fair')
->orderByDesc('id')
->value('id');
$this->assertGreaterThan(0, $chargeId);
$this->postJson('/api/v1/finance/event-charges/' . $chargeId . '/approve')
->assertOk()
->assertJsonPath('ok', true);
$this->postJson('/api/v1/finance/payments', [
'parent_id' => $parentId,
'invoice_id' => $invoiceId,
'total_amount' => 100,
'number_of_installments' => 1,
'payment_date' => '2025-10-01',
'payment_method' => 'cash',
'semester' => self::E2E_SEMESTER,
'school_year' => self::E2E_SCHOOL_YEAR,
])->assertStatus(201)->assertJsonPath('ok', true);
$this->assertDatabaseHas('payments', [
'parent_id' => $parentId,
'invoice_id' => $invoiceId,
]);
$this->getJson('/api/v1/finance/payments/parent/' . $parentId . '?school_year=' . self::E2E_SCHOOL_YEAR)
->assertOk()
->assertJsonPath('ok', true);
$this->getJson('/api/v1/finance/unpaid-parents')
->assertOk()
->assertJsonPath('ok', true);
}
}
@@ -0,0 +1,122 @@
<?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 E: Finance Edge Lifecycle (carryforward + installments + follow-ups).
*
* @see docs/alrahma_end_to_end_use_case_test_plan.md Scenario E
*/
class ScenarioEFinanceEdgeTest extends TestCase
{
use RefreshDatabase;
use SeedsE2ETestFixtures;
public function test_admin_carries_forward_balance_and_manages_installments(): void
{
$this->seedE2EConfiguration();
$this->seedClosedSchoolYear();
$admin = $this->actingAsApiAdministrator();
$parent = $this->createApiUserWithRole('parent');
$priorInvoiceId = $this->seedPriorYearUnpaidInvoice($parent->id);
$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,
]));
$preview->assertOk()->assertJsonPath('ok', true);
$this->assertNotEmpty($preview->json('preview.rows'));
$draft = $this->postJson('/api/v1/finance/carryforwards/draft', [
'from_school_year' => self::E2E_PREV_SCHOOL_YEAR,
'to_school_year' => self::E2E_SCHOOL_YEAR,
'parent_id' => $parent->id,
'reason' => 'E2E carryforward',
]);
$draft->assertOk()->assertJsonPath('ok', true);
$this->assertNotEmpty($draft->json('result.created'));
$carryforwardId = (int) $draft->json('result.created.0.id');
$this->postJson('/api/v1/finance/carryforwards/' . $carryforwardId . '/approve')
->assertOk()
->assertJsonPath('ok', true);
$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', [
'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')
->assertOk()
->assertJsonPath('ok', true);
$paymentId = DB::table('payments')->insertGetId([
'parent_id' => $parent->id,
'invoice_id' => $newInvoiceId,
'total_amount' => 75.00,
'paid_amount' => 75.00,
'balance' => 0,
'number_of_installments' => 1,
'payment_method' => 'cash',
'payment_date' => '2025-09-20',
'school_year' => self::E2E_SCHOOL_YEAR,
'semester' => self::E2E_SEMESTER,
'status' => 'Paid',
'created_at' => now(),
'updated_at' => now(),
]);
$this->postJson('/api/v1/finance/payments/' . $paymentId . '/allocate-installments', [])
->assertOk()
->assertJsonPath('ok', true);
$this->getJson('/api/v1/finance/installments/overdue')
->assertOk()
->assertJsonPath('ok', true);
$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', [
'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', [
'invoice_id' => $newInvoiceId,
'note' => 'Balance plan agreed.',
])->assertCreated()->assertJsonPath('ok', true);
$this->assertDatabaseHas('finance_balance_carryforwards', [
'id' => $carryforwardId,
'parent_id' => $parent->id,
'status' => 'posted_to_new_year',
]);
$this->assertDatabaseHas('invoices', [
'id' => $priorInvoiceId,
'parent_id' => $parent->id,
'school_year' => self::E2E_PREV_SCHOOL_YEAR,
]);
}
}
@@ -0,0 +1,93 @@
<?php
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;
/**
* Scenario F: Teacher Submission Follow-Up.
*
* @see docs/alrahma_end_to_end_use_case_test_plan.md Scenario F
*/
class ScenarioFTeacherSubmissionTest extends TestCase
{
use RefreshDatabase;
use SeedsE2ETestFixtures;
public function test_admin_detects_missing_submission_and_notifies_teacher(): void
{
Mail::fake();
$world = $this->seedTeacherClassWithStudent();
$admin = $this->actingAsApiAdministrator();
$teacher = $world['teacher'];
$classSectionId = $world['class_section_id'];
$this->actingAs($admin, 'api');
$report = $this->getJson('/api/v1/administrator/teacher-submissions');
$report->assertOk();
$this->assertNotEmpty($report->json('rows'));
$row = collect($report->json('rows'))
->firstWhere('class_section_id', $classSectionId);
$this->assertNotNull($row);
$this->assertNotEmpty($row['missing_items'] ?? []);
$notify = $this->postJson('/api/v1/administrator/teacher-submissions/notify', [
'notify' => [
$classSectionId => [
$teacher->id => 1,
],
],
'missing_items' => [
$classSectionId => [
$teacher->id => base64_encode(json_encode($row['missing_items'])),
],
],
]);
$notify->assertOk()->assertJsonPath('sent', 1);
$this->assertDatabaseHas('teacher_submission_notification_history', [
'teacher_id' => $teacher->id,
'class_section_id' => $classSectionId,
'admin_id' => $admin->id,
'notification_category' => 'teacher_submissions',
]);
// Teacher submits missing homework after reminder.
$this->actingAs($teacher, 'api');
$this->postJson('/api/v1/scores/homework', [
'class_section_id' => $classSectionId,
'semester' => self::E2E_SEMESTER,
'school_year' => self::E2E_SCHOOL_YEAR,
'scores' => [
$world['student_id'] => [
1 => 88,
],
],
])->assertOk()->assertJsonPath('ok', true);
$this->assertDatabaseHas('homework', [
'student_id' => $world['student_id'],
'class_section_id' => $classSectionId,
'homework_index' => 1,
'score' => 88,
]);
}
public function test_duplicate_notify_requires_valid_payload(): void
{
$admin = $this->actingAsApiAdministrator();
$this->actingAs($admin, 'api');
$this->postJson('/api/v1/administrator/teacher-submissions/notify', [
'missing_items' => [],
])->assertStatus(422)
->assertJsonValidationErrors(['notify']);
}
}
@@ -0,0 +1,75 @@
<?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 G: Print and Badge Workflow.
*
* @see docs/alrahma_end_to_end_use_case_test_plan.md Scenario G
*/
class ScenarioGPrintBadgeTest extends TestCase
{
use RefreshDatabase;
use SeedsE2ETestFixtures;
public function test_print_request_badge_log_and_scan_workflow(): void
{
$world = $this->seedTeacherClassWithStudent();
$teacher = $world['teacher'];
$classSectionId = $world['class_section_id'];
$admin = $this->actingAsApiAdministrator();
$this->actingAs($teacher, 'api');
$create = $this->postJson('/api/v1/print-requests/hand-copy', [
'class_id' => $classSectionId,
'num_copies' => 2,
'required_by' => '2026-09-13 11:00:00',
'pickup_method' => 'Self Pickup',
]);
$create->assertCreated();
$requestId = (int) $create->json('data.print_request.id');
$this->actingAs($admin, 'api');
foreach (['assigned', 'done', 'delivered'] as $status) {
$this->patchJson('/api/v1/print-requests/' . $requestId . '/status', [
'status' => $status,
])->assertOk();
}
$this->assertDatabaseHas('print_requests', [
'id' => $requestId,
'status' => 'delivered',
]);
$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', [
'user_ids' => [$teacher->id],
'school_year' => self::E2E_SCHOOL_YEAR,
'roles' => [(string) $teacher->id => 'Teacher'],
'classes' => [(string) $teacher->id => '3A'],
])->assertOk()->assertJsonPath('ok', true);
$this->postJson('/api/v1/badge_scan/scan', [
'badge_scan' => $badgeTag,
'school_year' => self::E2E_SCHOOL_YEAR,
'semester' => self::E2E_SEMESTER,
])->assertOk();
$this->assertDatabaseHas('scan_log', [
'user_id' => $teacher->id,
'card_id' => $badgeTag,
]);
$this->actingAs($admin, 'api');
$this->getJson('/api/v1/badge_scan/logs')
->assertOk()
->assertJsonPath('status', true);
}
}
@@ -0,0 +1,74 @@
<?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 H: Security and Unauthorized Access.
*
* @see docs/alrahma_end_to_end_use_case_test_plan.md Scenario H
*/
class ScenarioHSecurityUnauthorizedAccessTest extends TestCase
{
use RefreshDatabase;
use SeedsE2ETestFixtures;
public function test_cross_role_and_anonymous_access_is_blocked(): void
{
$this->seedE2EConfiguration();
$world = $this->seedTeacherClassWithStudent();
$parentA = $this->createApiUserWithRole('parent');
$teacher = $world['teacher'];
$otherSectionId = $this->seedClassSection(902, '8B', 8);
$otherTeacher = $this->createApiUserWithRole('teacher');
$this->seedTeacherClassAssignment($otherTeacher->id, $otherSectionId);
// Parent cannot update another family's student.
$this->actingAs($parentA, 'api');
$this->patchJson('/api/v1/parents/students/' . $world['student_id'], [
'firstname' => 'Blocked',
'lastname' => 'Update',
'dob' => '2014-01-01',
'gender' => 'Male',
])->assertNotFound();
// Teacher cannot submit attendance for a class they are not assigned to.
$this->actingAs($teacher, 'api');
$this->postJson('/api/v1/attendance/teacher/submit', [
'class_section_id' => $otherSectionId,
'date' => '2025-10-05',
'attendance' => [
['student_id' => $world['student_id'], 'status' => 'present'],
],
'teachers' => [
(string) $teacher->id => ['status' => 'present'],
],
])->assertStatus(422);
// Teacher cannot access admin-only dashboard routes.
$this->getJson('/api/v1/administrator/dashboard/metrics')->assertForbidden();
$this->getJson('/api/v1/print-requests/admin')->assertForbidden();
// Suspended user cannot log in after valid credentials.
$suspended = $this->createApiUserWithRole('parent', [
'email' => 'suspended.user@example.test',
'password' => Hash::make('secret123'),
'is_suspended' => 1,
]);
$this->postJson('/api/v1/auth/login', [
'email' => $suspended->email,
'password' => 'secret123',
])->assertStatus(403);
// Anonymous requests are rejected before business logic.
$this->actingAsGuest('api');
$this->getJson('/api/v1/finance/invoices/management')->assertUnauthorized();
$this->getJson('/api/v1/administrator/dashboard/metrics')->assertUnauthorized();
$this->getJson('/api/v1/parents/profile')->assertUnauthorized();
}
}
@@ -0,0 +1,282 @@
<?php
namespace Tests\Feature\Api\V1\Workflows;
use App\Models\Role;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Event;
use Tests\Concerns\CreatesApiTestUsers;
use Tests\TestCase;
/**
* End-to-end "happy path" for onboarding a family:
* 1. An administrator signs in.
* 2. They create a parent account.
* 3. They register a student under that parent.
* 4. They enrol the student into a class section.
* 5. They move the student's enrollment status to "enrolled".
*
* Each step uses the real HTTP API so the controllers, form requests,
* services and persistence layers are all exercised together.
*/
class StudentEnrollmentWorkflowTest extends TestCase
{
use CreatesApiTestUsers;
use RefreshDatabase;
private const SCHOOL_YEAR = '2025-2026';
private const SEMESTER = 'Fall';
public function test_admin_creates_account_adds_student_and_enrolls_them(): void
{
Event::fake();
// 1. Administrator is authenticated (also seeds roles + active school year).
$admin = $this->actingAsApiAdministrator();
$parentRoleId = (int) Role::query()->where('name', 'parent')->value('id');
$this->assertGreaterThan(0, $parentRoleId);
// 2. Create a brand-new parent account.
$createParent = $this->postJson('/api/v1/users', [
'firstname' => 'Khadija',
'lastname' => 'Saleh',
'gender' => 'Female',
'cellphone' => '5551234567',
'email' => 'khadija.saleh@example.test',
'address_street' => '42 Cedar Lane',
'city' => 'Brooklyn',
'state' => 'NY',
'zip' => '11201',
'accept_school_policy' => true,
'role_id' => $parentRoleId,
'password' => 'secret123',
'semester' => self::SEMESTER,
'school_year' => self::SCHOOL_YEAR,
'user_type' => 'primary',
'status' => 'Active',
'is_verified' => true,
]);
$createParent->assertStatus(201)->assertJsonPath('ok', true);
$parentId = (int) $createParent->json('user_id');
$this->assertGreaterThan(0, $parentId);
$this->assertDatabaseHas('users', [
'id' => $parentId,
'email' => 'khadija.saleh@example.test',
]);
$this->assertDatabaseHas('user_roles', [
'user_id' => $parentId,
'role_id' => $parentRoleId,
]);
// 3. Register the parent's first student (triggers the first-child
// emergency-contact branch in StudentController@store).
$createStudent = $this->postJson('/api/v1/students', [
'firstname' => 'Yusuf',
'lastname' => 'Saleh',
'dob' => '2015-05-10',
'age' => 10,
'gender' => 'Male',
'is_active' => true,
'registration_grade' => '5',
'is_new' => true,
'photo_consent' => true,
'parent_id' => $parentId,
'registration_date' => '2025-09-01',
'tuition_paid' => false,
'semester' => self::SEMESTER,
'year_of_registration' => 2025,
'school_year' => self::SCHOOL_YEAR,
// first-child contact details:
'name' => 'Khadija Saleh',
'cellphone' => '5551234567',
'email' => 'khadija.saleh@example.test',
'relation' => 'Mother',
]);
$createStudent->assertStatus(201)->assertJsonPath('ok', true);
$studentId = (int) $createStudent->json('student.id');
$this->assertGreaterThan(0, $studentId);
$this->assertDatabaseHas('students', [
'id' => $studentId,
'parent_id' => $parentId,
'firstname' => 'Yusuf',
'lastname' => 'Saleh',
]);
$this->assertDatabaseHas('emergency_contacts', [
'parent_id' => $parentId,
'emergency_contact_name' => 'Khadija Saleh',
'relation' => 'Mother',
]);
// 4. Enrol the student into a class section.
$classSectionId = $this->seedClassSection();
$enroll = $this->postJson('/api/v1/students/assign-class', [
'student_id' => $studentId,
'class_section_id' => [$classSectionId],
]);
$enroll->assertOk()->assertJsonPath('ok', true);
$this->assertDatabaseHas('student_class', [
'student_id' => $studentId,
'class_section_id' => $classSectionId,
'school_year' => self::SCHOOL_YEAR,
]);
// The class roster endpoint reflects the assignment.
$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?' .
http_build_query(['enrollment_status' => [$studentId => 'enrolled']]),
[]
);
$statusResponse->assertOk();
$this->assertDatabaseHas('enrollments', [
'student_id' => $studentId,
'parent_id' => $parentId,
'school_year' => self::SCHOOL_YEAR,
'semester' => self::SEMESTER,
'enrollment_status' => 'enrolled',
'admission_status' => 'accepted',
]);
}
public function test_admin_can_withdraw_an_enrolled_student(): void
{
Event::fake();
$this->actingAsApiAdministrator();
$parentRoleId = (int) Role::query()->where('name', 'parent')->value('id');
$parentId = (int) $this->postJson('/api/v1/users', [
'firstname' => 'Omar',
'lastname' => 'Hadid',
'gender' => 'Male',
'cellphone' => '5559876543',
'email' => 'omar.hadid@example.test',
'address_street' => '7 Maple Ct',
'city' => 'Queens',
'state' => 'NY',
'zip' => '11367',
'accept_school_policy' => true,
'role_id' => $parentRoleId,
'password' => 'secret123',
'semester' => self::SEMESTER,
'school_year' => self::SCHOOL_YEAR,
'user_type' => 'primary',
'status' => 'Active',
'is_verified' => true,
])->json('user_id');
$studentId = (int) $this->postJson('/api/v1/students', [
'firstname' => 'Layla',
'lastname' => 'Hadid',
'gender' => 'Female',
'is_active' => true,
'registration_grade' => '2',
'parent_id' => $parentId,
'semester' => self::SEMESTER,
'school_year' => self::SCHOOL_YEAR,
'name' => 'Omar Hadid',
'cellphone' => '5559876543',
'email' => 'omar.hadid@example.test',
'relation' => 'Father',
])->json('student.id');
// First enrol...
$this->post(
'/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?' .
http_build_query(['enrollment_status' => [$studentId => 'withdrawn']]),
[]
)->assertOk();
$this->assertDatabaseHas('enrollments', [
'student_id' => $studentId,
'parent_id' => $parentId,
'school_year' => self::SCHOOL_YEAR,
'enrollment_status' => 'withdrawn',
]);
}
public function test_creating_a_user_requires_a_valid_role(): void
{
$this->actingAsApiAdministrator();
$this->postJson('/api/v1/users', [
'firstname' => 'No',
'lastname' => 'Role',
'cellphone' => '5550001111',
'email' => 'no.role@example.test',
'address_street' => '1 Test St',
'city' => 'Town',
'state' => 'NY',
'zip' => '10001',
'accept_school_policy' => true,
'role_id' => 999999,
'password' => 'secret123',
'semester' => self::SEMESTER,
])->assertStatus(422)
->assertJsonValidationErrors(['role_id']);
}
public function test_student_registration_requires_first_child_contact_details(): void
{
$this->actingAsApiAdministrator();
$parentRoleId = (int) Role::query()->where('name', 'parent')->value('id');
$parentId = (int) $this->postJson('/api/v1/users', [
'firstname' => 'First',
'lastname' => 'Parent',
'gender' => 'Female',
'cellphone' => '5552223333',
'email' => 'first.parent@example.test',
'address_street' => '9 Birch Rd',
'city' => 'Bronx',
'state' => 'NY',
'zip' => '10451',
'accept_school_policy' => true,
'role_id' => $parentRoleId,
'password' => 'secret123',
'semester' => self::SEMESTER,
'school_year' => self::SCHOOL_YEAR,
])->json('user_id');
// First student for a parent must include name/cellphone/email.
$this->postJson('/api/v1/students', [
'firstname' => 'Solo',
'lastname' => 'Child',
'parent_id' => $parentId,
'school_year' => self::SCHOOL_YEAR,
])->assertStatus(422)
->assertJsonValidationErrors(['name', 'cellphone', 'email']);
}
private function seedClassSection(int $classSectionId = 701, int $classId = 1): int
{
DB::table('classSection')->insert([
'class_id' => $classId,
'class_section_id' => $classSectionId,
'class_section_name' => '5A',
'semester' => self::SEMESTER,
'school_year' => self::SCHOOL_YEAR,
]);
return $classSectionId;
}
}