postJson('/api/schools', [ 'name' => 'North Ridge School', 'code' => 'NRS', 'timezone' => 'America/New_York', ]); $response->assertCreated() ->assertJsonPath('data.name', 'North Ridge School') ->assertJsonPath('data.code', 'NRS'); $schoolId = (int) $response->json('data.id'); $this->actingAsSchoolUser($schoolId); $this->getJson("/api/schools/{$schoolId}") ->assertOk() ->assertJsonPath('data.timezone', 'America/New_York'); $this->assertDatabaseHas('schools', [ 'id' => $schoolId, 'code' => 'NRS', ]); } public function test_student_can_be_created_for_school_and_isolated_by_school(): void { $schoolA = $this->createSchool('School A', 'S-A'); $schoolB = $this->createSchool('School B', 'S-B'); $response = $this->postJson("/api/schools/{$schoolA}/students", [ 'first_name' => 'Maya', 'last_name' => 'Patel', 'student_number' => 'STU-1001', 'grade_level' => '7', ]); $response->assertCreated() ->assertJsonPath('data.first_name', 'Maya') ->assertJsonPath('data.student_number', 'STU-1001'); $studentId = $response->json('data.id'); $this->getJson("/api/schools/{$schoolA}/students/{$studentId}") ->assertOk() ->assertJsonPath('data.grade_level', '7'); $this->getJson("/api/schools/{$schoolB}/students/{$studentId}") ->assertNotFound(); } public function test_attendance_scan_records_once_and_duplicate_scan_is_idempotent(): void { $school = $this->createSchool(); $this->actingAsSchoolUser($school); $student = $this->createStudent($school, 'STU-2001'); $payload = [ 'student_number' => 'STU-2001', 'scanned_at' => '2026-05-29T08:15:00Z', 'source' => 'kiosk', ]; $this->postJson("/api/schools/{$school}/attendance/scans", $payload) ->assertCreated() ->assertJsonPath('data.status', 'recorded'); $this->postJson("/api/schools/{$school}/attendance/scans", $payload) ->assertOk() ->assertJsonPath('data.status', 'duplicate'); $this->assertDatabaseHas('attendance_records', [ 'school_id' => $school, 'subject_type' => 'student', 'subject_id' => $student, 'status' => 'present', ]); $this->assertSame(1, DB::table('attendance_records')->where('school_id', $school)->where('subject_id', $student)->count()); $this->assertSame(2, DB::table('attendance_scan_logs')->where('school_id', $school)->where('subject_id', $student)->count()); } public function test_communication_preview_has_no_side_effect_and_send_queues_recipient(): void { $school = $this->createSchool(); $this->actingAsSchoolUser($school); $student = $this->createStudent($school, 'STU-3001', 'Maya'); $this->createTemplate($school, 'attendance_absence', 'email', 'Hello, {{ student.first_name }} was marked absent.'); $this->postJson("/api/schools/{$school}/communications/preview", [ 'template_key' => 'attendance_absence', 'student_id' => $student, 'channel' => 'email', ])->assertOk() ->assertJsonPath('data.body', 'Hello, Maya was marked absent.'); $this->assertDatabaseCount('communication_messages', 0); $this->assertDatabaseCount('communication_message_recipients', 0); $this->postJson("/api/schools/{$school}/communications/send", [ 'template_key' => 'attendance_absence', 'student_id' => $student, 'channel' => 'email', ])->assertAccepted() ->assertJsonPath('data.status', 'queued'); $this->assertDatabaseHas('communication_messages', [ 'school_id' => $school, 'channel' => 'email', 'status' => 'queued', ]); $this->assertDatabaseHas('communication_message_recipients', [ 'school_id' => $school, 'recipient_type' => 'student', 'recipient_id' => $student, 'status' => 'queued', ]); } public function test_payment_file_imports_valid_rows_and_rejects_invalid_rows_without_partial_import(): void { $school = $this->createSchool(); $this->actingAsSchoolUser($school); $student = $this->createStudent($school, 'STU-4001'); $validFile = UploadedFile::fake()->createWithContent( 'payments.csv', "student_number,amount,paid_at,reference\nSTU-4001,125.00,2026-05-29,PAY-001\n" ); $this->postJson("/api/schools/{$school}/finance/payment-files", [ 'file' => $validFile, ])->assertCreated() ->assertJsonPath('data.imported_count', 1); $this->assertDatabaseHas('payments', [ 'school_id' => $school, 'student_id' => $student, 'amount_minor' => 12500, 'reference_number' => 'PAY-001', ]); $invalidFile = UploadedFile::fake()->createWithContent( 'bad-payments.csv', "student_number,amount,paid_at,reference\nSTU-4001,80.00,2026-05-29,PAY-002\nMISSING,90.00,2026-05-29,PAY-003\n" ); $this->postJson("/api/schools/{$school}/finance/payment-files", [ 'file' => $invalidFile, ])->assertUnprocessable(); $this->assertDatabaseMissing('payments', [ 'school_id' => $school, 'reference_number' => 'PAY-002', ]); $this->assertDatabaseMissing('payments', [ 'school_id' => $school, 'reference_number' => 'PAY-003', ]); } public function test_daily_report_aggregates_attendance_and_finance(): void { $school = $this->createSchool(); $this->actingAsSchoolUser($school); $student = $this->createStudent($school, 'STU-5001'); $this->postJson("/api/schools/{$school}/attendance/scans", [ 'student_number' => 'STU-5001', 'scanned_at' => '2026-05-29T08:15:00Z', 'source' => 'kiosk', ])->assertCreated(); DB::table('payments')->insert([ 'school_id' => $school, 'student_id' => $student, 'amount_minor' => 12500, 'currency' => 'USD', 'payment_method' => 'manual', 'payment_date' => '2026-05-29', 'reference_number' => 'PAY-RPT-001', 'status' => 'recorded', 'created_at' => now(), 'updated_at' => now(), ]); $this->getJson("/api/schools/{$school}/reports/daily?date=2026-05-29") ->assertOk() ->assertJsonPath('data.attendance.present_count', 1) ->assertJsonPath('data.finance.total_payments', 12500); } private function createSchool(string $name = 'Test School', ?string $code = null): int { return (int) DB::table('schools')->insertGetId([ 'name' => $name, 'code' => $code ?? 'SCH-'.uniqid(), 'domain_profile' => 'standard_school', 'timezone' => 'UTC', 'locale' => 'en', 'currency' => 'USD', 'active' => true, 'created_at' => now(), 'updated_at' => now(), ]); } private function createStudent(int $school, string $studentNumber, string $firstName = 'Maya'): int { return (int) DB::table('students')->insertGetId([ 'school_id' => $school, 'school_id_number' => $studentNumber, 'student_identifier' => $studentNumber, 'first_name' => $firstName, 'last_name' => 'Patel', 'status' => 'active', 'profile_metadata' => json_encode(['grade_level' => '7'], JSON_THROW_ON_ERROR), 'metadata' => json_encode(['grade_level' => '7'], JSON_THROW_ON_ERROR), 'created_at' => now(), 'updated_at' => now(), ]); } private function createTemplate(int $school, string $key, string $channel, string $body): int { return (int) DB::table('communication_templates')->insertGetId([ 'school_id' => $school, 'template_key' => $key, 'version' => '1', 'status' => 'active', 'channel' => $channel, 'message_category' => 'general', 'language' => 'en', 'subject' => 'Notice for {{ student.first_name }}', 'body' => $body, 'created_at' => now(), 'updated_at' => now(), ]); } private function actingAsSchoolUser(int $school): Authenticatable { $this->ensureAuthTestTablesExist(); $userId = (int) DB::table('users')->insertGetId([ 'name' => 'Workflow User '.$school, 'email' => 'workflow'.$school.'@example.test', 'password' => bcrypt('password'), 'created_at' => now(), 'updated_at' => now(), ]); DB::table('school_user')->insert([ 'school_id' => $school, 'user_id' => $userId, 'role' => 'admin', 'created_at' => now(), 'updated_at' => now(), ]); $user = new GenericUser([ 'id' => $userId, 'name' => 'Workflow User '.$school, 'email' => 'workflow'.$school.'@example.test', ]); $this->actingAs($user); return $user; } private function ensureAuthTestTablesExist(): void { if (! Schema::hasTable('users')) { Schema::create('users', function ($table): void { $table->id(); $table->string('name'); $table->string('email')->unique(); $table->string('password'); $table->rememberToken(); $table->timestamps(); }); } if (! Schema::hasTable('school_user')) { Schema::create('school_user', function ($table): void { $table->id(); $table->unsignedBigInteger('school_id')->index(); $table->unsignedBigInteger('user_id')->index(); $table->string('role')->default('admin'); $table->timestamps(); $table->unique(['school_id', 'user_id']); }); } } }