reconstruction of the project

This commit is contained in:
root
2026-03-08 16:33:24 -04:00
parent 23b7db1107
commit c8de5f7edc
9157 changed files with 77877 additions and 1073823 deletions
@@ -0,0 +1,25 @@
<?php
namespace Tests\Concerns;
use App\Models\Configuration;
trait SeedsAdministratorApiConfig
{
protected function seedAdministratorApiConfig(
string $schoolYear = '2025-2026',
string $semester = 'Fall'
): void {
Configuration::unguard();
Configuration::query()->updateOrCreate(
['config_key' => 'school_year'],
['config_value' => $schoolYear]
);
Configuration::query()->updateOrCreate(
['config_key' => 'semester'],
['config_value' => $semester]
);
}
}
@@ -1,169 +0,0 @@
<?php
namespace Tests\Feature\Api\Admin;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Tests\TestCase;
class AdminNotificationApiTest extends TestCase
{
use RefreshDatabase;
protected function createAdmin(): User
{
return User::factory()->create([
'role' => 'administrator',
'firstname' => 'Super',
'lastname' => 'Admin',
'email' => 'superadmin@example.com',
]);
}
protected function seedRoleDataForAdmins(): array
{
// roles + user_roles used by AdminNotificationService::fetchAdminNotificationUsers()
DB::table('roles')->insert([
['id' => 1, 'name' => 'administrator'],
['id' => 2, 'name' => 'teacher'],
]);
$adminA = User::factory()->create([
'firstname' => 'Nour',
'lastname' => 'Ali',
'email' => 'nour@example.com',
'role' => 'administrator',
]);
$adminB = User::factory()->create([
'firstname' => 'Sara',
'lastname' => 'Omar',
'email' => 'sara@example.com',
'role' => 'administrator',
]);
$teacher = User::factory()->create([
'firstname' => 'Teacher',
'lastname' => 'User',
'email' => 'teacher@example.com',
'role' => 'teacher',
]);
DB::table('user_roles')->insert([
['user_id' => $adminA->id, 'role_id' => 1, 'deleted_at' => null],
['user_id' => $adminB->id, 'role_id' => 1, 'deleted_at' => null],
['user_id' => $teacher->id, 'role_id' => 2, 'deleted_at' => null],
]);
return [$adminA, $adminB, $teacher];
}
/** @test */
public function admin_can_get_notification_alerts_payload(): void
{
$actingAdmin = $this->createAdmin();
$this->seedRoleDataForAdmins();
$response = $this->actingAs($actingAdmin, 'sanctum')
->getJson('/api/admin/notifications/alerts');
$response->assertOk()
->assertJsonStructure([
'admins',
'subjects',
'assigned_subjects',
'table_ready',
]);
}
/** @test */
public function admin_can_save_notification_subjects(): void
{
$actingAdmin = $this->createAdmin();
[$adminA, $adminB] = $this->seedRoleDataForAdmins();
$payload = [
'subjects' => [
$adminA->id => [
'finance' => true,
'attendance' => true,
],
$adminB->id => [
'general',
'events',
],
],
];
$response = $this->actingAs($actingAdmin, 'sanctum')
->postJson('/api/admin/notifications/subjects', $payload);
// If table is missing in your migrations, service returns 422 (ok=false)
if ($response->status() === 422) {
$response->assertJson([
'ok' => false,
]);
return;
}
$response->assertOk()
->assertJson([
'ok' => true,
]);
$this->assertDatabaseHas('admin_notification_subjects', [
'admin_id' => $adminA->id,
'subject' => 'finance',
]);
}
/** @test */
public function admin_can_get_print_recipients_payload(): void
{
$actingAdmin = $this->createAdmin();
$this->seedRoleDataForAdmins();
$response = $this->actingAs($actingAdmin, 'sanctum')
->getJson('/api/admin/notifications/print-recipients');
$response->assertOk()
->assertJsonStructure([
'admins',
'assigned',
'table_ready',
]);
}
/** @test */
public function admin_can_save_print_recipients(): void
{
$actingAdmin = $this->createAdmin();
[$adminA, $adminB] = $this->seedRoleDataForAdmins();
$payload = [
'notify' => [
(string)$adminA->id => true,
(string)$adminB->id => true,
],
];
$response = $this->actingAs($actingAdmin, 'sanctum')
->postJson('/api/admin/notifications/print-recipients', $payload);
if ($response->status() === 422) {
$response->assertJson(['ok' => false]);
return;
}
$response->assertOk()
->assertJson([
'ok' => true,
]);
$this->assertDatabaseHas('admin_notification_subjects', [
'admin_id' => $adminA->id,
'subject' => 'print_requests',
]);
}
}
@@ -1,373 +0,0 @@
<?php
namespace Tests\Feature\Api\Admin;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
use Tests\TestCase;
class AdminProgressApiTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
$this->createProgressTestTablesIfMissing();
config()->set('progress.status_options', [
'draft' => 'Draft',
'submitted' => 'Submitted',
'reviewed' => 'Reviewed',
'approved' => 'Approved',
'rejected' => 'Rejected',
]);
config()->set('progress.subject_sections', [
'quran' => ['Reading', 'Memorization'],
'arabic' => ['Reading', 'Writing'],
]);
}
/** @test */
public function it_lists_grouped_progress_reports(): void
{
$sectionId = $this->insertClassSection('Grade 5 - A');
$this->insertStudentClass($sectionId);
$this->insertProgressReport([
'class_section_id' => $sectionId,
'subject' => 'quran',
'status' => 'submitted',
'week_start' => '2026-02-09',
'week_end' => '2026-02-15',
]);
$this->insertProgressReport([
'class_section_id' => $sectionId,
'subject' => 'arabic',
'status' => 'submitted',
'week_start' => '2026-02-09',
'week_end' => '2026-02-15',
]);
$response = $this->getJson('/api/admin/progress');
$response->assertOk()
->assertJsonStructure([
'data' => [
'*' => [
'week_start',
'week_end',
'class_section_id',
'class_section_name',
'reports',
],
],
'meta' => [
'filters',
'class_sections',
'status_options',
'subject_sections',
],
]);
$data = $response->json('data');
$this->assertCount(1, $data);
$this->assertEquals('2026-02-09', $data[0]['week_start']);
$this->assertEquals($sectionId, $data[0]['class_section_id']);
$this->assertArrayHasKey('quran', $data[0]['reports']);
$this->assertArrayHasKey('arabic', $data[0]['reports']);
}
/** @test */
public function it_filters_progress_reports_by_status_and_class_section(): void
{
$sectionA = $this->insertClassSection('Grade 4 - A');
$sectionB = $this->insertClassSection('Grade 6 - B');
$this->insertStudentClass($sectionA);
$this->insertStudentClass($sectionB);
$this->insertProgressReport([
'class_section_id' => $sectionA,
'subject' => 'quran',
'status' => 'approved',
'week_start' => '2026-02-09',
'week_end' => '2026-02-15',
]);
$this->insertProgressReport([
'class_section_id' => $sectionB,
'subject' => 'arabic',
'status' => 'draft',
'week_start' => '2026-02-09',
'week_end' => '2026-02-15',
]);
$response = $this->getJson('/api/admin/progress?status=approved&class_section_id=' . $sectionA);
$response->assertOk();
$data = $response->json('data');
$this->assertCount(1, $data);
$this->assertEquals($sectionA, $data[0]['class_section_id']);
$this->assertArrayHasKey('quran', $data[0]['reports']);
$this->assertArrayNotHasKey('arabic', $data[0]['reports']);
}
/** @test */
public function it_shows_progress_report_with_weekly_reports_and_attachments(): void
{
$sectionId = $this->insertClassSection('Grade 5 - A');
$report1 = $this->insertProgressReport([
'class_section_id' => $sectionId,
'subject' => 'quran',
'status' => 'submitted',
'week_start' => '2026-02-09',
'week_end' => '2026-02-15',
'flags_json' => json_encode(['needs_followup' => true]),
]);
$report2 = $this->insertProgressReport([
'class_section_id' => $sectionId,
'subject' => 'arabic',
'status' => 'submitted',
'week_start' => '2026-02-09',
'week_end' => '2026-02-15',
]);
$this->insertAttachment($report1, [
'original_name' => 'quran-notes.pdf',
'file_path' => 'uploads/progress/quran-notes.pdf',
]);
$response = $this->getJson('/api/admin/progress/reports/' . $report1);
$response->assertOk()
->assertJsonStructure([
'data' => [
'report',
'weekly_reports',
'subject_sections',
],
]);
$this->assertEquals($report1, $response->json('data.report.id'));
$this->assertEquals('Submitted', $response->json('data.report.status_label'));
$this->assertTrue($response->json('data.report.flags.needs_followup'));
$weekly = $response->json('data.weekly_reports');
$this->assertCount(2, $weekly);
$quranReport = collect($weekly)->firstWhere('subject', 'quran');
$this->assertNotNull($quranReport);
$this->assertNotEmpty($quranReport['attachments']);
$this->assertEquals('quran-notes.pdf', $quranReport['attachments'][0]['name']);
$arabicReport = collect($weekly)->firstWhere('subject', 'arabic');
$this->assertNotNull($arabicReport);
$this->assertIsArray($arabicReport['attachments']);
}
/** @test */
public function it_returns_404_for_missing_progress_report(): void
{
$this->getJson('/api/admin/progress/reports/999999')
->assertStatus(404)
->assertJson([
'message' => 'Progress report not found.',
]);
}
/** @test */
public function it_downloads_legacy_attachment_from_report_attachment_path(): void
{
File::ensureDirectoryExists(storage_path('app/uploads/progress'));
$fullPath = storage_path('app/uploads/progress/legacy-file.txt');
File::put($fullPath, 'legacy attachment content');
$reportId = $this->insertProgressReport([
'class_section_id' => 1,
'subject' => 'quran',
'status' => 'submitted',
'week_start' => '2026-02-09',
'week_end' => '2026-02-15',
'attachment_path' => 'writable/uploads/progress/legacy-file.txt',
]);
$response = $this->get('/api/admin/progress/reports/' . $reportId . '/attachment');
$response->assertOk();
$response->assertHeader('content-disposition');
$this->assertStringContainsString('legacy-file.txt', $response->headers->get('content-disposition', ''));
}
/** @test */
public function it_returns_404_when_legacy_attachment_is_missing(): void
{
$reportId = $this->insertProgressReport([
'class_section_id' => 1,
'subject' => 'quran',
'status' => 'submitted',
'week_start' => '2026-02-09',
'week_end' => '2026-02-15',
'attachment_path' => 'writable/uploads/progress/missing.txt',
]);
$this->get('/api/admin/progress/reports/' . $reportId . '/attachment')
->assertStatus(404);
}
/** @test */
public function it_downloads_normalized_attachment_by_attachment_id(): void
{
File::ensureDirectoryExists(storage_path('app/uploads/progress'));
$fullPath = storage_path('app/uploads/progress/normalized-file.txt');
File::put($fullPath, 'normalized attachment content');
$reportId = $this->insertProgressReport([
'class_section_id' => 1,
'subject' => 'arabic',
'status' => 'approved',
'week_start' => '2026-02-09',
'week_end' => '2026-02-15',
]);
$attachmentId = $this->insertAttachment($reportId, [
'original_name' => 'Teacher Notes.txt',
'file_path' => 'writable/uploads/progress/normalized-file.txt',
]);
$response = $this->get('/api/admin/progress/attachments/' . $attachmentId);
$response->assertOk();
$this->assertStringContainsString('Teacher Notes.txt', $response->headers->get('content-disposition', ''));
}
/** @test */
public function it_returns_404_for_missing_normalized_attachment(): void
{
$this->get('/api/admin/progress/attachments/999999')
->assertStatus(404);
}
// ------------------------------------------------------------------
// Helpers
// ------------------------------------------------------------------
private function createProgressTestTablesIfMissing(): void
{
if (!Schema::hasTable('classSection')) {
Schema::create('classSection', function (Blueprint $table) {
$table->increments('class_section_id');
$table->string('class_section_name')->nullable();
});
}
if (!Schema::hasTable('student_class')) {
Schema::create('student_class', function (Blueprint $table) {
$table->increments('id');
$table->unsignedInteger('class_section_id');
$table->unsignedInteger('student_id')->nullable();
});
}
if (!Schema::hasTable('users')) {
Schema::create('users', function (Blueprint $table) {
$table->id();
$table->string('firstname')->nullable();
$table->string('lastname')->nullable();
$table->string('email')->nullable();
$table->string('password')->nullable();
$table->timestamps();
});
} else {
// Add columns if test app users table exists but lacks firstname/lastname
if (!Schema::hasColumn('users', 'firstname')) {
Schema::table('users', function (Blueprint $table) {
$table->string('firstname')->nullable();
});
}
if (!Schema::hasColumn('users', 'lastname')) {
Schema::table('users', function (Blueprint $table) {
$table->string('lastname')->nullable();
});
}
}
if (!Schema::hasTable('class_progress_reports')) {
Schema::create('class_progress_reports', function (Blueprint $table) {
$table->id();
$table->unsignedInteger('class_section_id')->nullable();
$table->unsignedBigInteger('teacher_id')->nullable();
$table->string('subject')->nullable();
$table->date('week_start')->nullable();
$table->date('week_end')->nullable();
$table->string('status')->nullable();
$table->text('flags_json')->nullable();
$table->string('attachment_path')->nullable();
$table->text('notes')->nullable();
$table->timestamps();
});
}
if (!Schema::hasTable('class_progress_attachments')) {
Schema::create('class_progress_attachments', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('report_id');
$table->string('original_name')->nullable();
$table->string('file_path')->nullable();
$table->timestamps();
});
}
}
private function insertClassSection(string $name): int
{
return (int) \DB::table('classSection')->insertGetId([
'class_section_name' => $name,
], 'class_section_id');
}
private function insertStudentClass(int $classSectionId): int
{
return (int) \DB::table('student_class')->insertGetId([
'class_section_id' => $classSectionId,
'student_id' => rand(1000, 9999),
]);
}
private function insertProgressReport(array $override = []): int
{
$data = array_merge([
'class_section_id' => 1,
'teacher_id' => null,
'subject' => 'quran',
'week_start' => '2026-02-09',
'week_end' => '2026-02-15',
'status' => 'draft',
'flags_json' => null,
'attachment_path' => null,
'notes' => 'Test report',
'created_at' => now(),
'updated_at' => now(),
], $override);
return (int) \DB::table('class_progress_reports')->insertGetId($data);
}
private function insertAttachment(int $reportId, array $override = []): int
{
$data = array_merge([
'report_id' => $reportId,
'original_name' => 'Attachment.pdf',
'file_path' => 'writable/uploads/progress/attachment.pdf',
'created_at' => now(),
'updated_at' => now(),
], $override);
return (int) \DB::table('class_progress_attachments')->insertGetId($data);
}
}
@@ -1,158 +0,0 @@
<?php
namespace Tests\Feature\Api\Admin;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Mail;
use Tests\TestCase;
class TeacherSubmissionApiTest extends TestCase
{
use RefreshDatabase;
protected function createAdmin(): User
{
$admin = User::factory()->create([
'role' => 'administrator',
'firstname' => 'Admin',
'lastname' => 'User',
'email' => 'admin@example.com',
]);
return $admin;
}
protected function seedSchoolConfig(): void
{
DB::table('configurations')->insert([
['config_key' => 'semester', 'config_value' => 'Fall'],
['config_key' => 'school_year', 'config_value' => '2025-2026'],
]);
}
/** @test */
public function admin_can_fetch_teacher_submission_report(): void
{
$admin = $this->createAdmin();
$this->seedSchoolConfig();
// Minimal records to allow report query to run
DB::table('classSection')->insert([
'class_section_id' => 1,
'class_section_name' => 'Grade 5 - A',
]);
$teacher = User::factory()->create([
'firstname' => 'Fatima',
'lastname' => 'Teacher',
'email' => 'teacher1@example.com',
]);
DB::table('teacher_class')->insert([
'teacher_id' => $teacher->id,
'class_section_id' => 1,
'position' => 'main',
'semester' => 'Fall',
'school_year' => '2025-2026',
]);
$response = $this->actingAs($admin, 'sanctum')
->getJson('/api/admin/teacher-submissions/report');
$response->assertOk()
->assertJsonStructure([
'semester',
'school_year',
'rows',
'notification_history',
'summary' => [
'total_items',
'missing_items',
'submitted_items',
'submission_percentage',
],
]);
}
/** @test */
public function admin_can_send_teacher_submission_notifications(): void
{
Mail::fake();
$admin = $this->createAdmin();
$this->seedSchoolConfig();
DB::table('classSection')->insert([
'class_section_id' => 2,
'class_section_name' => 'Grade 6 - B',
]);
$teacher = User::factory()->create([
'firstname' => 'Amina',
'lastname' => 'Smith',
'email' => 'amina@example.com',
]);
// optional history table insert compatibility depends on schema; no need to seed rows
$payload = [
'notify' => [
2 => [
$teacher->id => 1,
],
],
'missing_items' => [
2 => [
$teacher->id => base64_encode(json_encode(['midterm scores', 'PTAP comments'])),
],
],
];
$response = $this->actingAs($admin, 'sanctum')
->postJson('/api/admin/teacher-submissions/notifications', $payload);
$response->assertOk()
->assertJsonStructure([
'message',
'sent',
'failed',
'results' => [
'*' => [
'teacher_id',
'class_section_id',
'teacher_name',
'section_name',
'email',
'status',
'missing_items',
'error',
],
],
]);
Mail::assertSentCount(1);
}
/** @test */
public function teacher_submission_notifications_requires_notify_payload(): void
{
$admin = $this->createAdmin();
$response = $this->actingAs($admin, 'sanctum')
->postJson('/api/admin/teacher-submissions/notifications', []);
$response->assertStatus(422)
->assertJsonValidationErrors(['notify']);
}
/** @test */
public function non_admin_cannot_access_teacher_submission_endpoints(): void
{
$user = User::factory()->create(['role' => 'teacher']);
$this->actingAs($user, 'sanctum')
->getJson('/api/admin/teacher-submissions/report')
->assertStatus(403);
}
}
@@ -0,0 +1,124 @@
<?php
namespace Tests\Feature\Api\V1\Admin;
use App\Models\User;
use App\Services\Administrator\AdministratorAbsenceService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Laravel\Sanctum\Sanctum;
use Mockery;
use Tests\TestCase;
class AdministratorAbsenceControllerTest extends TestCase
{
use RefreshDatabase;
protected function tearDown(): void
{
Mockery::close();
parent::tearDown();
}
public function test_guest_cannot_access_absence_index(): void
{
$response = $this->getJson('/api/administrator/absence');
$response->assertStatus(401);
}
public function test_authenticated_user_can_get_absence_index(): void
{
$user = User::factory()->create();
Sanctum::actingAs($user);
$mock = Mockery::mock(AdministratorAbsenceService::class);
$mock->shouldReceive('getAbsenceFormData')
->once()
->with($user->id)
->andReturn([
'admin_name' => 'Test Admin',
'semester' => 'Fall',
'schoolYear' => '2025-2026',
'existing' => [],
'availableDates' => ['2026-03-08'],
]);
$this->app->instance(AdministratorAbsenceService::class, $mock);
$response = $this->getJson('/api/administrator/absence');
$response->assertOk()
->assertJson([
'admin_name' => 'Test Admin',
'semester' => 'Fall',
'schoolYear' => '2025-2026',
]);
}
public function test_absence_store_requires_reason(): void
{
$user = User::factory()->create();
Sanctum::actingAs($user);
$response = $this->postJson('/api/administrator/absence', [
'dates' => ['2026-03-08'],
'reason_type' => 'vacation',
'reason' => '',
]);
$response->assertStatus(422)
->assertJsonValidationErrors(['reason']);
}
public function test_absence_store_requires_valid_date_format(): void
{
$user = User::factory()->create();
Sanctum::actingAs($user);
$response = $this->postJson('/api/administrator/absence', [
'dates' => ['03/08/2026'],
'reason_type' => 'vacation',
'reason' => 'Family trip',
]);
$response->assertStatus(422)
->assertJsonValidationErrors(['dates.0']);
}
public function test_absence_store_calls_service_and_returns_success(): void
{
$user = User::factory()->create();
Sanctum::actingAs($user);
$payload = [
'dates' => ['2026-03-08'],
'reason_type' => 'vacation',
'reason' => 'Family trip',
];
$mock = Mockery::mock(AdministratorAbsenceService::class);
$mock->shouldReceive('submit')
->once()
->andReturn([
'message' => '1 day(s) saved as absent.',
'saved' => 1,
'dates' => ['2026-03-08'],
'status' => 200,
]);
$this->app->instance(AdministratorAbsenceService::class, $mock);
$response = $this->postJson('/api/administrator/absence', $payload);
$response->assertOk()
->assertJson([
'message' => '1 day(s) saved as absent.',
'saved' => 1,
'dates' => ['2026-03-08'],
]);
}
}
@@ -0,0 +1,131 @@
<?php
namespace Tests\Feature\Api\Administrator;
use App\Models\User;
use App\Services\Administrator\AdministratorEnrollmentQueryService;
use App\Services\Administrator\AdministratorEnrollmentStatusService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Laravel\Sanctum\Sanctum;
use Mockery;
use Tests\TestCase;
class AdministratorEnrollmentControllerTest extends TestCase
{
use RefreshDatabase;
protected function tearDown(): void
{
Mockery::close();
parent::tearDown();
}
public function test_guest_cannot_update_enrollment_statuses(): void
{
$response = $this->postJson('/api/administrator/enrollment-withdrawal/update-statuses', [
'enrollment_status' => [
10 => 'enrolled',
],
]);
$response->assertStatus(401);
}
public function test_can_fetch_enrollment_withdrawal_data(): void
{
$user = User::factory()->create();
Sanctum::actingAs($user);
$mock = Mockery::mock(AdministratorEnrollmentQueryService::class);
$mock->shouldReceive('enrollmentWithdrawalData')
->once()
->with('2025-2026')
->andReturn([
'students' => [],
'classes' => [],
'semester' => 'Fall',
'school_year' => '2025-2026',
'schoolYears' => ['2025-2026'],
]);
$this->app->instance(AdministratorEnrollmentQueryService::class, $mock);
$response = $this->getJson('/api/administrator/enrollment-withdrawal?schoolYear=2025-2026');
$response->assertOk()
->assertJson([
'semester' => 'Fall',
'school_year' => '2025-2026',
]);
}
public function test_can_fetch_new_students(): void
{
$user = User::factory()->create();
Sanctum::actingAs($user);
$mock = Mockery::mock(AdministratorEnrollmentQueryService::class);
$mock->shouldReceive('newStudentsData')
->once()
->andReturn([
'new_students' => [],
'total_new' => 0,
]);
$this->app->instance(AdministratorEnrollmentQueryService::class, $mock);
$response = $this->getJson('/api/administrator/enrollment-withdrawal/new-students');
$response->assertOk()
->assertJson([
'new_students' => [],
'total_new' => 0,
]);
}
public function test_update_statuses_requires_valid_status_values(): void
{
$user = User::factory()->create();
Sanctum::actingAs($user);
$response = $this->postJson('/api/administrator/enrollment-withdrawal/update-statuses', [
'enrollment_status' => [
10 => 'bad-status',
],
]);
$response->assertStatus(422)
->assertJsonValidationErrors(['enrollment_status.10']);
}
public function test_update_statuses_calls_service(): void
{
$user = User::factory()->create();
Sanctum::actingAs($user);
$payload = [
'enrollment_status' => [
10 => 'enrolled',
11 => 'payment pending',
],
];
$mock = Mockery::mock(AdministratorEnrollmentStatusService::class);
$mock->shouldReceive('updateStatuses')
->once()
->with($payload['enrollment_status'], $user->id)
->andReturn([
'message' => 'Enrollment statuses updated and notifications sent.',
'status' => 200,
]);
$this->app->instance(AdministratorEnrollmentStatusService::class, $mock);
$response = $this->postJson('/api/administrator/enrollment-withdrawal/update-statuses', $payload);
$response->assertOk()
->assertJson([
'message' => 'Enrollment statuses updated and notifications sent.',
]);
}
}
@@ -0,0 +1,227 @@
<?php
namespace Tests\Feature\Api\V1\Admin;
use App\Models\ClassSection;
use App\Models\Configuration;
use App\Models\Enrollment;
use App\Models\Student;
use App\Models\StudentClass;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Laravel\Sanctum\Sanctum;
use Tests\TestCase;
class AdministratorEnrollmentQueryApiTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
// Seed config expected by AdministratorSharedService
Configuration::unguard();
Configuration::query()->create([
'config_key' => 'school_year',
'config_value' => '2025-2026',
]);
Configuration::query()->create([
'config_key' => 'semester',
'config_value' => 'Fall',
]);
}
public function test_enrollment_withdrawal_endpoint_returns_students_classes_and_years(): void
{
$admin = User::factory()->create();
Sanctum::actingAs($admin);
$parentOne = User::factory()->create([
'firstname' => 'Parent',
'lastname' => 'One',
]);
$parentTwo = User::factory()->create([
'firstname' => 'Parent',
'lastname' => 'Two',
]);
$studentOne = Student::query()->create([
'id' => 1,
'parent_id' => $parentOne->id,
'firstname' => 'Ali',
'lastname' => 'Ben',
'is_new' => 1,
'registration_date' => '2025-09-10 08:00:00',
'admission_status' => 'accepted',
]);
$studentTwo = Student::query()->create([
'id' => 2,
'parent_id' => $parentTwo->id,
'firstname' => 'Sara',
'lastname' => 'Ali',
'is_new' => 0,
'registration_date' => '2024-09-10 08:00:00',
'admission_status' => 'accepted',
]);
ClassSection::unguard();
ClassSection::query()->create([
'class_section_id' => 10,
'class_section_name' => 'Grade 5A',
'school_year' => '2025-2026',
'semester' => 'Fall',
]);
StudentClass::unguard();
StudentClass::query()->create([
'student_id' => $studentOne->id,
'class_section_id' => 10,
'school_year' => '2025-2026',
'semester' => 'Fall',
]);
Enrollment::unguard();
Enrollment::query()->create([
'student_id' => $studentOne->id,
'parent_id' => $parentOne->id,
'school_year' => '2025-2026',
'semester' => 'Fall',
'enrollment_status' => 'enrolled',
'admission_status' => 'accepted',
'is_withdrawn' => 0,
'enrollment_date' => now()->toDateString(),
]);
Enrollment::query()->create([
'student_id' => $studentTwo->id,
'parent_id' => $parentTwo->id,
'school_year' => '2024-2025',
'semester' => 'Fall',
'enrollment_status' => 'withdrawn',
'admission_status' => 'accepted',
'is_withdrawn' => 1,
'enrollment_date' => now()->subYear()->toDateString(),
]);
$response = $this->getJson('/api/administrator/enrollment-withdrawal?schoolYear=2025-2026');
$response->assertOk()
->assertJson([
'semester' => 'Fall',
'school_year' => '2025-2026',
])
->assertJsonCount(2, 'students')
->assertJsonCount(1, 'classes');
$students = collect($response->json('students'));
$first = $students->firstWhere('student_id', 1);
$second = $students->firstWhere('student_id', 2);
$this->assertSame('Yes', $first['new_student']);
$this->assertSame('Grade 5A', $first['class_section']);
$this->assertSame('enrolled', $first['enrollment_status']);
$this->assertSame('2025-09-10', $first['registration_date_order']);
$this->assertSame('No', $second['new_student']);
$this->assertSame('Yes', $second['removed_previous_year']);
}
public function test_new_students_endpoint_returns_transformed_rows(): void
{
$admin = User::factory()->create();
Sanctum::actingAs($admin);
$parent = User::factory()->create([
'firstname' => 'Parent',
'lastname' => 'House',
]);
$studentOne = Student::query()->create([
'id' => 9,
'parent_id' => $parent->id,
'firstname' => 'Adam',
'lastname' => 'Y',
'is_new' => 1,
'registration_date' => '2025-10-01 10:00:00',
'admission_status' => 'accepted',
]);
$studentTwo = Student::query()->create([
'id' => 10,
'parent_id' => $parent->id,
'firstname' => 'Eve',
'lastname' => 'Z',
'is_new' => 0,
'registration_date' => null,
'admission_status' => 'accepted',
]);
ClassSection::unguard();
ClassSection::query()->create([
'class_section_id' => 20,
'class_section_name' => 'Grade 1A',
'school_year' => '2025-2026',
'semester' => 'Fall',
]);
StudentClass::unguard();
StudentClass::query()->create([
'student_id' => $studentOne->id,
'class_section_id' => 20,
'school_year' => '2025-2026',
'semester' => 'Fall',
]);
Enrollment::unguard();
Enrollment::query()->create([
'student_id' => $studentOne->id,
'parent_id' => $parent->id,
'school_year' => '2025-2026',
'semester' => 'Fall',
'enrollment_status' => 'enrolled',
'admission_status' => 'accepted',
'is_withdrawn' => 0,
'enrollment_date' => now()->toDateString(),
]);
Enrollment::query()->create([
'student_id' => $studentTwo->id,
'parent_id' => $parent->id,
'school_year' => '2025-2026',
'semester' => 'Fall',
'enrollment_status' => 'waitlist',
'admission_status' => 'pending',
'is_withdrawn' => 0,
'enrollment_date' => now()->toDateString(),
]);
$response = $this->getJson('/api/administrator/enrollment-withdrawal/new-students');
$response->assertOk()
->assertJson([
'total_new' => 2,
])
->assertJsonCount(2, 'new_students');
$students = collect($response->json('new_students'));
$first = $students->firstWhere('id', 9);
$second = $students->firstWhere('id', 10);
$this->assertSame('Yes', $first['new_student']);
$this->assertSame('Grade 1A', $first['class_section']);
$this->assertSame('enrolled', $first['enrollment_status']);
$this->assertSame('contact_9', $first['modalIdContact']);
$this->assertSame('2025-10-01', $first['registration_date']);
$this->assertSame('No', $second['new_student']);
$this->assertSame('Class not Assigned', $second['class_section']);
$this->assertSame('waitlist', $second['enrollment_status']);
}
}
@@ -0,0 +1,282 @@
<?php
namespace Tests\Feature\Api\Administrator;
use App\Models\Configuration;
use App\Models\Enrollment;
use App\Models\Invoice;
use App\Models\RefundModel as Refund;
use App\Models\Student;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Event;
use Laravel\Sanctum\Sanctum;
use Tests\TestCase;
class AdministratorEnrollmentUpdateStatusesApiTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
Configuration::unguard();
Configuration::query()->create([
'config_key' => 'school_year',
'config_value' => '2025-2026',
]);
Configuration::query()->create([
'config_key' => 'semester',
'config_value' => 'Fall',
]);
}
public function test_update_statuses_requires_enrollment_status_payload(): void
{
$admin = User::factory()->create();
Sanctum::actingAs($admin);
$response = $this->postJson('/api/administrator/enrollment-withdrawal/update-statuses', []);
$response->assertStatus(422)
->assertJsonValidationErrors(['enrollment_status']);
}
public function test_update_statuses_rejects_invalid_status_value(): void
{
$admin = User::factory()->create();
Sanctum::actingAs($admin);
$response = $this->postJson('/api/administrator/enrollment-withdrawal/update-statuses', [
'enrollment_status' => [
10 => 'not-valid',
],
]);
$response->assertStatus(422)
->assertJsonValidationErrors(['enrollment_status.10']);
}
public function test_update_statuses_creates_new_enrollment_row(): void
{
Event::fake();
$admin = User::factory()->create(['id' => 900]);
Sanctum::actingAs($admin);
$parent = User::factory()->create([
'id' => 100,
'firstname' => 'Parent',
'lastname' => 'One',
'email' => 'parent1@test.com',
]);
Student::unguard();
Student::query()->create([
'id' => 10,
'parent_id' => $parent->id,
'firstname' => 'Ali',
'lastname' => 'Ben',
'admission_status' => 'pending',
]);
$response = $this->postJson('/api/administrator/enrollment-withdrawal/update-statuses', [
'enrollment_status' => [
10 => 'enrolled',
],
]);
$response->assertOk()
->assertJson([
'message' => 'Enrollment statuses updated and notifications sent.',
]);
$this->assertDatabaseHas('enrollments', [
'student_id' => 10,
'parent_id' => 100,
'school_year' => '2025-2026',
'semester' => 'Fall',
'enrollment_status' => 'enrolled',
'admission_status' => 'accepted',
'is_withdrawn' => 0,
]);
}
public function test_update_statuses_updates_existing_enrollment_row(): void
{
Event::fake();
$admin = User::factory()->create(['id' => 901]);
Sanctum::actingAs($admin);
$parent = User::factory()->create([
'id' => 101,
'firstname' => 'Parent',
'lastname' => 'Two',
'email' => 'parent2@test.com',
]);
Student::unguard();
Student::query()->create([
'id' => 11,
'parent_id' => $parent->id,
'firstname' => 'Sara',
'lastname' => 'Ali',
'admission_status' => 'pending',
]);
Enrollment::unguard();
Enrollment::query()->create([
'student_id' => 11,
'parent_id' => 101,
'school_year' => '2025-2026',
'semester' => 'Fall',
'enrollment_status' => 'payment pending',
'admission_status' => 'accepted',
'is_withdrawn' => 0,
'enrollment_date' => now()->toDateString(),
]);
$response = $this->postJson('/api/administrator/enrollment-withdrawal/update-statuses', [
'enrollment_status' => [
11 => 'withdrawn',
],
]);
$response->assertOk();
$this->assertDatabaseHas('enrollments', [
'student_id' => 11,
'parent_id' => 101,
'school_year' => '2025-2026',
'enrollment_status' => 'withdrawn',
'admission_status' => 'pending',
]);
}
public function test_update_statuses_creates_or_updates_refund_when_status_is_refund_pending(): void
{
Event::fake();
$admin = User::factory()->create(['id' => 902]);
Sanctum::actingAs($admin);
$parent = User::factory()->create([
'id' => 102,
'firstname' => 'Parent',
'lastname' => 'Three',
'email' => 'parent3@test.com',
]);
Student::unguard();
Student::query()->create([
'id' => 12,
'parent_id' => $parent->id,
'firstname' => 'Mina',
'lastname' => 'K',
'admission_status' => 'accepted',
]);
Enrollment::unguard();
Enrollment::query()->create([
'student_id' => 12,
'parent_id' => 102,
'school_year' => '2025-2026',
'semester' => 'Fall',
'enrollment_status' => 'enrolled',
'admission_status' => 'accepted',
'is_withdrawn' => 0,
'enrollment_date' => now()->toDateString(),
]);
Invoice::unguard();
$invoice = Invoice::query()->create([
'parent_id' => 102,
'school_year' => '2025-2026',
'balance' => 125.00,
'amount_due' => 125.00,
'total_amount' => 125.00,
'due_date' => now()->addDays(10)->toDateString(),
]);
$response = $this->postJson('/api/administrator/enrollment-withdrawal/update-statuses', [
'enrollment_status' => [
12 => 'refund pending',
],
]);
$response->assertOk();
$this->assertDatabaseHas('enrollments', [
'student_id' => 12,
'parent_id' => 102,
'school_year' => '2025-2026',
'enrollment_status' => 'refund pending',
]);
$this->assertDatabaseHas('refunds', [
'parent_id' => 102,
'invoice_id' => $invoice->id,
'status' => 'Pending',
'updated_by' => 902,
]);
$refund = Refund::query()->where('invoice_id', $invoice->id)->first();
$this->assertNotNull($refund);
$this->assertGreaterThanOrEqual(0, (float) $refund->refund_amount);
}
public function test_update_statuses_returns_partial_success_when_some_students_are_invalid_for_processing(): void
{
Event::fake();
$admin = User::factory()->create(['id' => 903]);
Sanctum::actingAs($admin);
$parent = User::factory()->create([
'id' => 103,
'firstname' => 'Parent',
'lastname' => 'Four',
'email' => 'parent4@test.com',
]);
Student::unguard();
Student::query()->create([
'id' => 13,
'parent_id' => $parent->id,
'firstname' => 'Adam',
'lastname' => 'Q',
'admission_status' => 'pending',
]);
Student::query()->create([
'id' => 14,
'parent_id' => null,
'firstname' => 'No',
'lastname' => 'Parent',
'admission_status' => 'pending',
]);
$response = $this->postJson('/api/administrator/enrollment-withdrawal/update-statuses', [
'enrollment_status' => [
13 => 'enrolled',
14 => 'enrolled',
],
]);
$response->assertStatus(207);
$this->assertDatabaseHas('enrollments', [
'student_id' => 13,
'parent_id' => 103,
'school_year' => '2025-2026',
'enrollment_status' => 'enrolled',
]);
$this->assertDatabaseMissing('enrollments', [
'student_id' => 14,
'school_year' => '2025-2026',
]);
}
}
@@ -0,0 +1,160 @@
<?php
namespace Tests\Feature\Api\Administrator;
use App\Models\User;
use App\Services\Administrator\AdminNotificationSubjectService;
use App\Services\Administrator\AdminPrintRecipientService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Laravel\Sanctum\Sanctum;
use Mockery;
use Tests\TestCase;
class AdministratorNotificationControllerTest extends TestCase
{
use RefreshDatabase;
protected function tearDown(): void
{
Mockery::close();
parent::tearDown();
}
public function test_can_get_alerts_data(): void
{
$user = User::factory()->create();
Sanctum::actingAs($user);
$mock = Mockery::mock(AdminNotificationSubjectService::class);
$mock->shouldReceive('alertsData')
->once()
->andReturn([
'admins' => [],
'subjects' => [
'academics' => 'Academics',
],
'assignedSubjects' => [],
'tableReady' => true,
]);
$this->app->instance(AdminNotificationSubjectService::class, $mock);
$response = $this->getJson('/api/administrator/notifications/alerts');
$response->assertOk()
->assertJson([
'admins' => [],
'assignedSubjects' => [],
'tableReady' => true,
]);
}
public function test_save_alerts_requires_subjects_payload(): void
{
$user = User::factory()->create();
Sanctum::actingAs($user);
$response = $this->postJson('/api/administrator/notifications/alerts', []);
$response->assertStatus(422)
->assertJsonValidationErrors(['subjects']);
}
public function test_save_alerts_calls_subject_service(): void
{
$user = User::factory()->create();
Sanctum::actingAs($user);
$payload = [
'subjects' => [
1 => ['academics', 'finance'],
],
];
$mock = Mockery::mock(AdminNotificationSubjectService::class);
$mock->shouldReceive('save')
->once()
->with($payload['subjects'])
->andReturn([
'message' => 'Notification subjects updated.',
'status' => 200,
]);
$this->app->instance(AdminNotificationSubjectService::class, $mock);
$response = $this->postJson('/api/administrator/notifications/alerts', $payload);
$response->assertOk()
->assertJson([
'message' => 'Notification subjects updated.',
]);
}
public function test_can_get_print_recipients_data(): void
{
$user = User::factory()->create();
Sanctum::actingAs($user);
$mock = Mockery::mock(AdminPrintRecipientService::class);
$mock->shouldReceive('data')
->once()
->andReturn([
'admins' => [],
'assigned' => [],
'tableReady' => true,
]);
$this->app->instance(AdminPrintRecipientService::class, $mock);
$response = $this->getJson('/api/administrator/notifications/print-recipients');
$response->assertOk()
->assertJson([
'admins' => [],
'assigned' => [],
'tableReady' => true,
]);
}
public function test_save_print_recipients_requires_notify_payload(): void
{
$user = User::factory()->create();
Sanctum::actingAs($user);
$response = $this->postJson('/api/administrator/notifications/print-recipients', []);
$response->assertStatus(422)
->assertJsonValidationErrors(['notify']);
}
public function test_save_print_recipients_calls_service(): void
{
$user = User::factory()->create();
Sanctum::actingAs($user);
$payload = [
'notify' => [
1 => 1,
2 => 1,
],
];
$mock = Mockery::mock(AdminPrintRecipientService::class);
$mock->shouldReceive('save')
->once()
->with($payload['notify'])
->andReturn([
'message' => 'Print notification recipients updated.',
'status' => 200,
]);
$this->app->instance(AdminPrintRecipientService::class, $mock);
$response = $this->postJson('/api/administrator/notifications/print-recipients', $payload);
$response->assertOk()
->assertJson([
'message' => 'Print notification recipients updated.',
]);
}
}
@@ -0,0 +1,108 @@
<?php
namespace Tests\Feature\Api\Administrator;
use App\Models\User;
use App\Services\Administrator\TeacherSubmissionNotificationService;
use App\Services\Administrator\TeacherSubmissionReportService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Laravel\Sanctum\Sanctum;
use Mockery;
use Tests\TestCase;
class AdministratorTeacherSubmissionControllerTest extends TestCase
{
use RefreshDatabase;
protected function tearDown(): void
{
Mockery::close();
parent::tearDown();
}
public function test_can_get_teacher_submission_report(): void
{
$user = User::factory()->create();
Sanctum::actingAs($user);
$mock = Mockery::mock(TeacherSubmissionReportService::class);
$mock->shouldReceive('report')
->once()
->andReturn([
'rows' => [],
'semester' => 'Fall',
'schoolYear' => '2025-2026',
'notificationHistory' => [],
'summary' => [
'total_items' => 0,
'missing_items' => 0,
'submitted_items' => 0,
'submission_percentage' => 100,
],
]);
$this->app->instance(TeacherSubmissionReportService::class, $mock);
$response = $this->getJson('/api/administrator/teacher-submissions');
$response->assertOk()
->assertJson([
'rows' => [],
'semester' => 'Fall',
'schoolYear' => '2025-2026',
]);
}
public function test_notify_requires_notify_payload(): void
{
$user = User::factory()->create();
Sanctum::actingAs($user);
$response = $this->postJson('/api/administrator/teacher-submissions/notify', [
'missing_items' => [],
]);
$response->assertStatus(422)
->assertJsonValidationErrors(['notify']);
}
public function test_notify_calls_service(): void
{
$user = User::factory()->create();
Sanctum::actingAs($user);
$payload = [
'notify' => [
1 => [
5 => 1,
],
],
'missing_items' => [
1 => [
5 => base64_encode(json_encode(['midterm scores'])),
],
],
];
$mock = Mockery::mock(TeacherSubmissionNotificationService::class);
$mock->shouldReceive('send')
->once()
->andReturn([
'message' => '1 reminder sent',
'sent' => 1,
'failed' => 0,
'status' => 200,
]);
$this->app->instance(TeacherSubmissionNotificationService::class, $mock);
$response = $this->postJson('/api/administrator/teacher-submissions/notify', $payload);
$response->assertOk()
->assertJson([
'message' => '1 reminder sent',
'sent' => 1,
'failed' => 0,
]);
}
}
@@ -0,0 +1,170 @@
<?php
namespace Tests\Feature\Api\Administrator;
use App\Models\Configuration;
use App\Models\TeacherSubmissionNotificationHistory;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Mail;
use Laravel\Sanctum\Sanctum;
use Tests\TestCase;
class AdministratorTeacherSubmissionNotifyApiTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
Configuration::unguard();
Configuration::query()->create([
'config_key' => 'school_year',
'config_value' => '2025-2026',
]);
Configuration::query()->create([
'config_key' => 'semester',
'config_value' => 'Fall',
]);
}
public function test_notify_endpoint_requires_notify_payload(): void
{
$admin = User::factory()->create();
Sanctum::actingAs($admin);
$response = $this->postJson('/api/administrator/teacher-submissions/notify', [
'missing_items' => [],
]);
$response->assertStatus(422)
->assertJsonValidationErrors(['notify']);
}
public function test_notify_endpoint_sends_email_and_creates_history_row(): void
{
Mail::fake();
$admin = User::factory()->create([
'id' => 900,
'firstname' => 'Admin',
'lastname' => 'One',
'email' => 'admin@test.com',
]);
Sanctum::actingAs($admin);
DB::table('users')->insert([
[
'id' => 100,
'firstname' => 'John',
'lastname' => 'Teacher',
'email' => 'teacher@test.com',
],
]);
DB::table('classSection')->insert([
[
'class_section_id' => 10,
'class_section_name' => 'Grade 5A',
],
]);
$payload = [
'notify' => [
10 => [
100 => 1,
],
],
'missing_items' => [
10 => [
100 => base64_encode(json_encode([
'midterm scores',
'attendance',
])),
],
],
];
$response = $this->postJson('/api/administrator/teacher-submissions/notify', $payload);
$response->assertOk()
->assertJson([
'sent' => 1,
'failed' => 0,
]);
Mail::assertSentCount(1);
$this->assertDatabaseHas('teacher_submission_notification_history', [
'teacher_id' => 100,
'class_section_id' => 10,
'admin_id' => 900,
'notification_category' => 'teacher_submissions',
'status' => 'sent',
'school_year' => '2025-2026',
'semester' => 'Fall',
]);
$history = TeacherSubmissionNotificationHistory::query()->latest('id')->first();
$this->assertNotNull($history);
$this->assertStringContainsString('midterm scores and attendance', strtolower((string) $history->message));
}
public function test_notify_endpoint_marks_failed_when_teacher_email_is_invalid(): void
{
Mail::fake();
$admin = User::factory()->create([
'id' => 901,
'firstname' => 'Admin',
'lastname' => 'Two',
]);
Sanctum::actingAs($admin);
DB::table('users')->insert([
[
'id' => 101,
'firstname' => 'Bad',
'lastname' => 'Email',
'email' => 'not-an-email',
],
]);
DB::table('classSection')->insert([
[
'class_section_id' => 11,
'class_section_name' => 'Grade 6A',
],
]);
$payload = [
'notify' => [
11 => [
101 => 1,
],
],
];
$response = $this->postJson('/api/administrator/teacher-submissions/notify', $payload);
$response->assertStatus(207)
->assertJson([
'sent' => 0,
'failed' => 1,
]);
Mail::assertNothingSent();
$this->assertDatabaseHas('teacher_submission_notification_history', [
'teacher_id' => 101,
'class_section_id' => 11,
'admin_id' => 901,
'notification_category' => 'teacher_submissions',
'status' => 'failed',
'school_year' => '2025-2026',
'semester' => 'Fall',
]);
}
}
@@ -0,0 +1,292 @@
<?php
namespace Tests\Feature\Api\Administrator;
use App\Models\AttendanceDay;
use App\Models\Configuration;
use App\Models\ScoreComment;
use App\Models\SemesterScore;
use App\Models\TeacherSubmissionNotificationHistory;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Laravel\Sanctum\Sanctum;
use Tests\TestCase;
class AdministratorTeacherSubmissionReportApiTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
Configuration::unguard();
Configuration::query()->create([
'config_key' => 'school_year',
'config_value' => '2025-2026',
]);
Configuration::query()->create([
'config_key' => 'semester',
'config_value' => 'Fall',
]);
}
public function test_teacher_submission_report_endpoint_returns_rows_summary_and_history(): void
{
$admin = User::factory()->create(['id' => 900, 'firstname' => 'Admin', 'lastname' => 'One']);
Sanctum::actingAs($admin);
DB::table('users')->insert([
[
'id' => 100,
'firstname' => 'Main',
'lastname' => 'Teacher',
'email' => 'main@test.com',
],
[
'id' => 101,
'firstname' => 'TA',
'lastname' => 'Teacher',
'email' => 'ta@test.com',
],
]);
DB::table('classSection')->insert([
[
'class_section_id' => 10,
'class_section_name' => 'Grade 5A',
],
]);
DB::table('teacher_class')->insert([
[
'class_section_id' => 10,
'teacher_id' => 100,
'position' => 'main',
'school_year' => '2025-2026',
'semester' => 'Fall',
],
[
'class_section_id' => 10,
'teacher_id' => 101,
'position' => 'ta',
'school_year' => '2025-2026',
'semester' => 'Fall',
],
]);
DB::table('student_class')->insert([
[
'student_id' => 1,
'class_section_id' => 10,
'school_year' => '2025-2026',
'semester' => 'Fall',
],
[
'student_id' => 2,
'class_section_id' => 10,
'school_year' => '2025-2026',
'semester' => 'Fall',
],
]);
SemesterScore::unguard();
SemesterScore::query()->create([
'student_id' => 1,
'class_section_id' => 10,
'school_year' => '2025-2026',
'semester' => 'Fall',
'midterm_exam_score' => '88',
'participation_score' => '10',
]);
SemesterScore::query()->create([
'student_id' => 2,
'class_section_id' => 10,
'school_year' => '2025-2026',
'semester' => 'Fall',
'midterm_exam_score' => '',
'participation_score' => '9',
]);
ScoreComment::unguard();
ScoreComment::query()->create([
'student_id' => 1,
'school_year' => '2025-2026',
'semester' => 'Fall',
'score_type' => 'midterm',
'comment' => 'Good progress',
]);
ScoreComment::query()->create([
'student_id' => 1,
'school_year' => '2025-2026',
'semester' => 'Fall',
'score_type' => 'ptap',
'comment' => 'Great effort',
]);
AttendanceDay::unguard();
AttendanceDay::query()->create([
'class_section_id' => 10,
'school_year' => '2025-2026',
'semester' => 'Fall',
'date' => now()->format('Y-m-d'),
'status' => 'submitted',
]);
TeacherSubmissionNotificationHistory::unguard();
TeacherSubmissionNotificationHistory::query()->create([
'teacher_id' => 100,
'class_section_id' => 10,
'admin_id' => 900,
'notification_category' => 'teacher_submissions',
'message' => 'Reminder sent',
'status' => 'sent',
'school_year' => '2025-2026',
'semester' => 'Fall',
'sent_at' => now(),
]);
$response = $this->getJson('/api/administrator/teacher-submissions');
$response->assertOk()
->assertJson([
'semester' => 'Fall',
'schoolYear' => '2025-2026',
])
->assertJsonCount(1, 'rows');
$row = $response->json('rows.0');
$this->assertSame('Grade 5A', $row['class_section']);
$this->assertSame(10, $row['class_section_id']);
$this->assertCount(2, $row['teachers']);
$this->assertSame('Main: Main Teacher', $row['teachers'][0]['label']);
$this->assertSame('TA: TA Teacher', $row['teachers'][1]['label']);
$this->assertSame('Missing', $row['midterm_score_status']['label']);
$this->assertSame('1/2', $row['midterm_score_status']['detail']);
$this->assertSame('Missing', $row['midterm_comment_status']['label']);
$this->assertSame('1/2', $row['midterm_comment_status']['detail']);
$this->assertSame('Submitted', $row['participation_status']['label']);
$this->assertSame('2/2', $row['participation_status']['detail']);
$this->assertSame('Missing', $row['ptap_comment_status']['label']);
$this->assertSame('1/2', $row['ptap_comment_status']['detail']);
$this->assertSame('Submitted', $row['attendance_status']['label']);
$this->assertSame([
'midterm scores',
'midterm comments',
'PTAP comments',
], $row['missing_items']);
$summary = $response->json('summary');
$this->assertSame(5, $summary['total_items']);
$this->assertSame(3, $summary['missing_items']);
$this->assertSame(2, $summary['submitted_items']);
$this->assertSame(40, $summary['submission_percentage']);
$history = $response->json('notificationHistory');
$this->assertSame('Admin One', $history['10']['100'][0]['admin_name']);
}
public function test_teacher_submission_report_returns_no_students_status_for_empty_section(): void
{
$admin = User::factory()->create();
Sanctum::actingAs($admin);
DB::table('users')->insert([
[
'id' => 200,
'firstname' => 'Solo',
'lastname' => 'Teacher',
'email' => 'solo@test.com',
],
]);
DB::table('classSection')->insert([
[
'class_section_id' => 11,
'class_section_name' => 'Grade 6A',
],
]);
DB::table('teacher_class')->insert([
[
'class_section_id' => 11,
'teacher_id' => 200,
'position' => 'main',
'school_year' => '2025-2026',
'semester' => 'Fall',
],
]);
$response = $this->getJson('/api/administrator/teacher-submissions');
$response->assertOk()
->assertJsonCount(1, 'rows');
$row = $response->json('rows.0');
$this->assertSame('No students', $row['midterm_score_status']['label']);
$this->assertSame('No students', $row['midterm_comment_status']['label']);
$this->assertSame('No students', $row['participation_status']['label']);
$this->assertSame('No students', $row['ptap_comment_status']['label']);
$this->assertSame([], $row['missing_items']);
$this->assertSame(0, $row['student_count']);
}
public function test_teacher_submission_report_limits_history_to_three_entries(): void
{
$admin = User::factory()->create(['id' => 901, 'firstname' => 'Admin', 'lastname' => 'Two']);
Sanctum::actingAs($admin);
DB::table('users')->insert([
[
'id' => 300,
'firstname' => 'Hist',
'lastname' => 'Teacher',
'email' => 'hist@test.com',
],
]);
DB::table('classSection')->insert([
[
'class_section_id' => 12,
'class_section_name' => 'Grade 7A',
],
]);
DB::table('teacher_class')->insert([
[
'class_section_id' => 12,
'teacher_id' => 300,
'position' => 'main',
'school_year' => '2025-2026',
'semester' => 'Fall',
],
]);
TeacherSubmissionNotificationHistory::unguard();
for ($i = 0; $i < 4; $i++) {
TeacherSubmissionNotificationHistory::query()->create([
'teacher_id' => 300,
'class_section_id' => 12,
'admin_id' => 901,
'notification_category' => 'teacher_submissions',
'message' => 'Reminder ' . $i,
'status' => 'sent',
'school_year' => '2025-2026',
'semester' => 'Fall',
'sent_at' => now()->subMinutes($i),
]);
}
$response = $this->getJson('/api/administrator/teacher-submissions');
$response->assertOk();
$history = $response->json('notificationHistory.12.300');
$this->assertCount(3, $history);
}
}
@@ -0,0 +1,421 @@
<?php
namespace Tests\Feature\Api;
use App\Models\ClassSection;
use App\Models\Configuration;
use App\Models\Student;
use App\Models\StudentClass;
use App\Models\TeacherClass;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class AssignmentApiControllerTest extends TestCase
{
use RefreshDatabase;
protected User $user;
protected User $teacherMain;
protected User $teacherAssistant;
protected ClassSection $sectionA;
protected ClassSection $sectionB;
protected Student $studentOne;
protected Student $studentTwo;
protected function setUp(): void
{
parent::setUp();
$this->seedConfigurations();
$this->user = User::factory()->create();
$this->teacherMain = User::factory()->create([
'firstname' => 'Main',
'lastname' => 'Teacher',
]);
$this->teacherAssistant = User::factory()->create([
'firstname' => 'Assistant',
'lastname' => 'Teacher',
]);
$this->sectionA = ClassSection::factory()->create([
'section_name' => 'Grade 1 - A',
]);
$this->sectionB = ClassSection::factory()->create([
'section_name' => 'Grade 2 - B',
]);
$this->studentOne = Student::factory()->create([
'firstname' => 'Ali',
'lastname' => 'Hassan',
'age' => 8,
'gender' => 'Male',
'registration_grade' => 'Grade 1',
'photo_consent' => 1,
'tuition_paid' => 0,
'school_id' => 'SCH-001',
'is_active' => 1,
]);
$this->studentTwo = Student::factory()->create([
'firstname' => 'Sara',
'lastname' => 'Ibrahim',
'age' => 9,
'gender' => 'Female',
'registration_grade' => 'Grade 2',
'photo_consent' => 0,
'tuition_paid' => 1,
'school_id' => 'SCH-002',
'is_active' => 1,
]);
}
protected function seedConfigurations(): void
{
Configuration::query()->create([
'config_key' => 'semester',
'config_value' => 'Fall',
]);
Configuration::query()->create([
'config_key' => 'school_year',
'config_value' => '2025-2026',
]);
}
public function test_index_returns_assignment_overview(): void
{
TeacherClass::query()->create([
'teacher_id' => $this->teacherMain->id,
'class_section_id' => $this->sectionA->id,
'position' => 'main',
'semester' => 'Fall',
'school_year' => '2025-2026',
'description' => 'Morning group',
]);
TeacherClass::query()->create([
'teacher_id' => $this->teacherAssistant->id,
'class_section_id' => $this->sectionA->id,
'position' => 'ta',
'semester' => 'Fall',
'school_year' => '2025-2026',
'description' => 'Morning group',
]);
StudentClass::query()->create([
'student_id' => $this->studentOne->id,
'class_section_id' => $this->sectionA->id,
'semester' => 'Fall',
'school_year' => '2025-2026',
'description' => 'Morning group',
'updated_by' => $this->user->id,
]);
$response = $this->actingAs($this->user, 'sanctum')
->getJson('/api/assignments?school_year=2025-2026&semester=Fall');
$response->assertOk()
->assertJsonPath('message', 'Assignments retrieved successfully.')
->assertJsonPath('data.schoolYear', '2025-2026')
->assertJsonPath('data.selectedYear', '2025-2026')
->assertJsonPath('data.selectedSemester', 'Fall')
->assertJsonPath('data.semester', 'Fall')
->assertJsonPath('data.current_school_year', '2025-2026')
->assertJsonCount(1, 'data.classSections')
->assertJsonPath('data.classSections.0.class_section_id', $this->sectionA->id)
->assertJsonPath('data.classSections.0.class_section_name', 'Grade 1 - A')
->assertJsonPath('data.classSections.0.main_teachers.0', 'Main Teacher')
->assertJsonPath('data.classSections.0.teacher_assistants.0', 'Assistant Teacher')
->assertJsonPath('data.classSections.0.students.0.firstname', 'Ali')
->assertJsonPath('data.classSections.0.students.0.lastname', 'Hassan')
->assertJsonPath('data.classSections.0.students.0.photo_consent', true)
->assertJsonPath('data.classSections.0.students.0.tuition_paid', false)
->assertJsonPath('data.classSections.0.description', 'Morning group');
}
public function test_index_filters_by_school_year(): void
{
TeacherClass::query()->create([
'teacher_id' => $this->teacherMain->id,
'class_section_id' => $this->sectionA->id,
'position' => 'main',
'semester' => 'Fall',
'school_year' => '2025-2026',
'description' => 'Included',
]);
TeacherClass::query()->create([
'teacher_id' => $this->teacherAssistant->id,
'class_section_id' => $this->sectionB->id,
'position' => 'main',
'semester' => 'Fall',
'school_year' => '2024-2025',
'description' => 'Excluded',
]);
StudentClass::query()->create([
'student_id' => $this->studentOne->id,
'class_section_id' => $this->sectionA->id,
'semester' => 'Fall',
'school_year' => '2025-2026',
'description' => 'Included',
'updated_by' => $this->user->id,
]);
StudentClass::query()->create([
'student_id' => $this->studentTwo->id,
'class_section_id' => $this->sectionB->id,
'semester' => 'Fall',
'school_year' => '2024-2025',
'description' => 'Excluded',
'updated_by' => $this->user->id,
]);
$response = $this->actingAs($this->user, 'sanctum')
->getJson('/api/assignments?school_year=2025-2026');
$response->assertOk()
->assertJsonCount(1, 'data.classSections')
->assertJsonPath('data.classSections.0.class_section_name', 'Grade 1 - A');
}
public function test_index_sorts_sections_by_name(): void
{
TeacherClass::query()->create([
'teacher_id' => $this->teacherMain->id,
'class_section_id' => $this->sectionB->id,
'position' => 'main',
'semester' => 'Fall',
'school_year' => '2025-2026',
'description' => 'B group',
]);
TeacherClass::query()->create([
'teacher_id' => $this->teacherAssistant->id,
'class_section_id' => $this->sectionA->id,
'position' => 'main',
'semester' => 'Fall',
'school_year' => '2025-2026',
'description' => 'A group',
]);
$response = $this->actingAs($this->user, 'sanctum')
->getJson('/api/assignments');
$response->assertOk();
$sections = $response->json('data.classSections');
$this->assertCount(2, $sections);
$this->assertSame('Grade 1 - A', $sections[0]['class_section_name']);
$this->assertSame('Grade 2 - B', $sections[1]['class_section_name']);
}
public function test_index_excludes_inactive_students(): void
{
$inactiveStudent = Student::factory()->create([
'firstname' => 'Hidden',
'lastname' => 'Student',
'is_active' => 0,
]);
TeacherClass::query()->create([
'teacher_id' => $this->teacherMain->id,
'class_section_id' => $this->sectionA->id,
'position' => 'main',
'semester' => 'Fall',
'school_year' => '2025-2026',
'description' => 'Morning group',
]);
StudentClass::query()->create([
'student_id' => $inactiveStudent->id,
'class_section_id' => $this->sectionA->id,
'semester' => 'Fall',
'school_year' => '2025-2026',
'description' => 'Should not show',
'updated_by' => $this->user->id,
]);
$response = $this->actingAs($this->user, 'sanctum')
->getJson('/api/assignments');
$response->assertOk()
->assertJsonPath('data.classSections.0.students', []);
}
public function test_store_creates_new_assignment(): void
{
$payload = [
'student_id' => $this->studentOne->id,
'class_section_id' => $this->sectionA->id,
'semester' => 'Fall',
'school_year' => '2025-2026',
'description' => 'New assignment',
];
$response = $this->actingAs($this->user, 'sanctum')
->postJson('/api/assignments', $payload);
$response->assertCreated()
->assertJsonPath('message', 'Assignment saved successfully.')
->assertJsonPath('data.student_id', $this->studentOne->id)
->assertJsonPath('data.class_section_id', $this->sectionA->id)
->assertJsonPath('data.semester', 'Fall')
->assertJsonPath('data.school_year', '2025-2026')
->assertJsonPath('data.description', 'New assignment');
$this->assertDatabaseHas('student_class', [
'student_id' => $this->studentOne->id,
'class_section_id' => $this->sectionA->id,
'semester' => 'Fall',
'school_year' => '2025-2026',
'description' => 'New assignment',
'updated_by' => $this->user->id,
]);
}
public function test_store_updates_existing_assignment_for_same_unique_keys(): void
{
StudentClass::query()->create([
'student_id' => $this->studentOne->id,
'class_section_id' => $this->sectionA->id,
'semester' => 'Fall',
'school_year' => '2025-2026',
'description' => 'Old description',
'updated_by' => $this->user->id,
]);
$payload = [
'student_id' => $this->studentOne->id,
'class_section_id' => $this->sectionA->id,
'semester' => 'Fall',
'school_year' => '2025-2026',
'description' => 'Updated description',
];
$response = $this->actingAs($this->user, 'sanctum')
->postJson('/api/assignments', $payload);
$response->assertCreated()
->assertJsonPath('data.description', 'Updated description');
$this->assertDatabaseHas('student_class', [
'student_id' => $this->studentOne->id,
'class_section_id' => $this->sectionA->id,
'semester' => 'Fall',
'school_year' => '2025-2026',
'description' => 'Updated description',
]);
$this->assertSame(
1,
StudentClass::query()
->where('student_id', $this->studentOne->id)
->where('class_section_id', $this->sectionA->id)
->where('semester', 'Fall')
->where('school_year', '2025-2026')
->count()
);
}
public function test_store_validates_required_fields(): void
{
$response = $this->actingAs($this->user, 'sanctum')
->postJson('/api/assignments', []);
$response->assertUnprocessable()
->assertJsonValidationErrors([
'student_id',
'class_section_id',
'semester',
'school_year',
]);
}
public function test_store_validates_foreign_keys(): void
{
$payload = [
'student_id' => 999999,
'class_section_id' => 999999,
'semester' => 'Fall',
'school_year' => '2025-2026',
'description' => 'Bad ids',
];
$response = $this->actingAs($this->user, 'sanctum')
->postJson('/api/assignments', $payload);
$response->assertUnprocessable()
->assertJsonValidationErrors([
'student_id',
'class_section_id',
]);
}
public function test_class_assignment_data_returns_expected_structure(): void
{
TeacherClass::query()->create([
'teacher_id' => $this->teacherMain->id,
'class_section_id' => $this->sectionA->id,
'position' => 'main',
'semester' => 'Fall',
'school_year' => '2025-2026',
'description' => 'Morning group',
]);
StudentClass::query()->create([
'student_id' => $this->studentOne->id,
'class_section_id' => $this->sectionA->id,
'semester' => 'Fall',
'school_year' => '2025-2026',
'description' => 'Morning group',
'updated_by' => $this->user->id,
]);
$response = $this->actingAs($this->user, 'sanctum')
->getJson('/api/assignments/class-assignment-data');
$response->assertOk()
->assertJsonPath('message', 'Class assignment data retrieved successfully.')
->assertJsonPath('data.semester', 'Fall')
->assertJsonPath('data.school_year', '2025-2026')
->assertJsonCount(1, 'data.classSections')
->assertJsonPath('data.classSections.0.class_section_name', 'Grade 1 - A')
->assertJsonPath('data.classSections.0.main_teachers.0', 'Main Teacher')
->assertJsonPath('data.classSections.0.students.0.firstname', 'Ali');
}
public function test_class_assignment_data_deduplicates_teacher_names(): void
{
TeacherClass::query()->create([
'teacher_id' => $this->teacherMain->id,
'class_section_id' => $this->sectionA->id,
'position' => 'main',
'semester' => 'Fall',
'school_year' => '2025-2026',
'description' => 'Morning group',
]);
TeacherClass::query()->create([
'teacher_id' => $this->teacherMain->id,
'class_section_id' => $this->sectionA->id,
'position' => 'main',
'semester' => 'Fall',
'school_year' => '2025-2026',
'description' => 'Morning group',
]);
$response = $this->actingAs($this->user, 'sanctum')
->getJson('/api/assignments/class-assignment-data');
$response->assertOk();
$mainTeachers = $response->json('data.classSections.0.main_teachers');
$this->assertSame(['Main Teacher'], $mainTeachers);
}
}
+8 -7
View File
@@ -10,6 +10,7 @@ use App\Models\ClassSection;
use App\Models\Configuration;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Laravel\Sanctum\Sanctum;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;
class AssignmentApiTest extends TestCase
@@ -39,7 +40,7 @@ class AssignmentApiTest extends TestCase
);
}
/** @test */
#[Test]
public function it_returns_assignment_sections_json(): void
{
$section = ClassSection::factory()->create([
@@ -108,7 +109,7 @@ class AssignmentApiTest extends TestCase
$response->assertJsonPath('classSections.0.students.0.id', $student->id);
}
/** @test */
#[Test]
public function it_filters_assignments_by_school_year(): void
{
$section1 = ClassSection::factory()->create(['name' => 'Grade 4']);
@@ -144,7 +145,7 @@ class AssignmentApiTest extends TestCase
$response->assertJsonPath('selectedYear', '2025-2026');
}
/** @test */
#[Test]
public function it_creates_or_updates_student_assignment(): void
{
$student = Student::factory()->create(['is_active' => 1]);
@@ -171,7 +172,7 @@ class AssignmentApiTest extends TestCase
]);
}
/** @test */
#[Test]
public function it_validates_required_fields_when_storing_assignment(): void
{
$response = $this->postJson('/api/assignments', []);
@@ -183,7 +184,7 @@ class AssignmentApiTest extends TestCase
]);
}
/** @test */
#[Test]
public function store_is_idempotent_for_same_student_semester_and_year(): void
{
$student = Student::factory()->create(['is_active' => 1]);
@@ -224,7 +225,7 @@ class AssignmentApiTest extends TestCase
]);
}
/** @test */
#[Test]
public function guest_cannot_access_assignment_api_when_auth_is_required(): void
{
auth()->guard('sanctum')->logout();
@@ -233,4 +234,4 @@ class AssignmentApiTest extends TestCase
$response->assertStatus(401);
}
}
}
@@ -0,0 +1,326 @@
<?php
namespace Tests\Feature\Api\V1\AttendanceTracking;
use App\Services\AttendanceTrackingService;
use Mockery;
use Tests\TestCase;
class AttendanceTrackingControllerTest extends TestCase
{
protected function tearDown(): void
{
Mockery::close();
parent::tearDown();
}
protected function mockService(): \Mockery\MockInterface
{
$service = Mockery::mock(AttendanceTrackingService::class);
$this->app->instance(AttendanceTrackingService::class, $service);
return $service;
}
public function test_pending_violations_returns_json_response(): void
{
$service = $this->mockService();
$service->shouldReceive('getPendingViolations')
->once()
->with('2025-2026', 'Fall')
->andReturn([
'students' => [
['id' => 1, 'name' => 'John Doe'],
],
'school_year' => '2025-2026',
'semester' => 'Fall',
]);
$response = $this->getJson('/api/attendance-tracking/pending-violations?school_year=2025-2026&semester=Fall');
$response->assertOk()
->assertJson([
'success' => true,
'data' => [
'school_year' => '2025-2026',
'semester' => 'Fall',
],
]);
}
public function test_notified_violations_returns_json_response(): void
{
$service = $this->mockService();
$service->shouldReceive('getNotifiedViolations')
->once()
->with('2025-2026', 'Fall')
->andReturn([
'students' => [],
'school_year' => '2025-2026',
'semester' => 'Fall',
]);
$response = $this->getJson('/api/attendance-tracking/notified-violations?school_year=2025-2026&semester=Fall');
$response->assertOk()
->assertJson([
'success' => true,
'data' => [
'school_year' => '2025-2026',
'semester' => 'Fall',
],
]);
}
public function test_student_case_returns_service_payload(): void
{
$service = $this->mockService();
$service->shouldReceive('getStudentCase')
->once()
->andReturn([
'success' => true,
'data' => [
'student' => ['id' => 10, 'name' => 'John Doe'],
'attendance' => [],
],
]);
$response = $this->getJson('/api/attendance-tracking/student-case/10');
$response->assertOk()
->assertJson([
'success' => true,
'data' => [
'student' => ['id' => 10, 'name' => 'John Doe'],
],
]);
}
public function test_student_case_returns_service_status_code_when_not_found(): void
{
$service = $this->mockService();
$service->shouldReceive('getStudentCase')
->once()
->andReturn([
'success' => false,
'message' => 'Student not found',
'status' => 404,
]);
$response = $this->getJson('/api/attendance-tracking/student-case/999');
$response->assertStatus(404)
->assertJson([
'success' => false,
'message' => 'Student not found',
]);
}
public function test_record_validates_and_returns_service_response(): void
{
$service = $this->mockService();
$payload = [
'student_id' => 10,
'date' => '2026-03-01',
'parent_email' => 'parent@example.com',
'parent_name' => 'Jane Doe',
'subject_type' => 'Absent',
];
$service->shouldReceive('record')
->once()
->with($payload)
->andReturn([
'success' => true,
'message' => 'Notification queued.',
]);
$response = $this->postJson('/api/attendance-tracking/record', $payload);
$response->assertOk()
->assertJson([
'success' => true,
'message' => 'Notification queued.',
]);
}
public function test_record_returns_validation_errors_for_bad_payload(): void
{
$response = $this->postJson('/api/attendance-tracking/record', [
'student_id' => 0,
'date' => 'bad-date',
]);
$response->assertStatus(422)
->assertJsonValidationErrors(['student_id', 'date']);
}
public function test_send_auto_emails_returns_service_response(): void
{
$service = $this->mockService();
$service->shouldReceive('sendAutoEmails')
->once()
->with('default')
->andReturn([
'success' => true,
'sent' => 2,
'skipped' => 1,
'errors' => 0,
'details' => [],
]);
$response = $this->postJson('/api/attendance-tracking/send-auto-emails', [
'variant' => 'default',
]);
$response->assertOk()
->assertJson([
'success' => true,
'sent' => 2,
'skipped' => 1,
'errors' => 0,
]);
}
public function test_compose_returns_service_response(): void
{
$service = $this->mockService();
$service->shouldReceive('compose')
->once()
->with(10, 'ABS_1', 'default', '2026-03-01')
->andReturn([
'success' => true,
'data' => [
'student_id' => 10,
'subject' => 'Test Subject',
],
]);
$response = $this->getJson('/api/attendance-tracking/compose?student_id=10&code=ABS_1&variant=default&incident_date=2026-03-01');
$response->assertOk()
->assertJson([
'success' => true,
'data' => [
'student_id' => 10,
'subject' => 'Test Subject',
],
]);
}
public function test_send_manual_email_returns_service_response(): void
{
$service = $this->mockService();
$payload = [
'student_id' => 10,
'to' => 'parent@example.com',
'subject' => 'Hello',
'body_html' => 'Body',
'code' => 'ABS_1',
'variant' => 'default',
'incident_date' => '2026-03-01',
];
$service->shouldReceive('sendEmailManual')
->once()
->with($payload)
->andReturn([
'success' => true,
'message' => 'Email sent successfully.',
]);
$response = $this->postJson('/api/attendance-tracking/send-manual-email', $payload);
$response->assertOk()
->assertJson([
'success' => true,
'message' => 'Email sent successfully.',
]);
}
public function test_send_manual_email_validates_payload(): void
{
$response = $this->postJson('/api/attendance-tracking/send-manual-email', [
'student_id' => 0,
'to' => 'not-an-email',
]);
$response->assertStatus(422)
->assertJsonValidationErrors(['student_id', 'to', 'subject', 'body_html', 'code']);
}
public function test_parents_info_returns_service_response(): void
{
$service = $this->mockService();
$service->shouldReceive('parentsInfo')
->once()
->with(10)
->andReturn([
'success' => true,
'data' => [
'primary' => ['email' => 'parent@example.com'],
'secondary' => ['email' => 'second@example.com'],
],
]);
$response = $this->getJson('/api/attendance-tracking/parents-info?student_id=10');
$response->assertOk()
->assertJson([
'success' => true,
'data' => [
'primary' => ['email' => 'parent@example.com'],
'secondary' => ['email' => 'second@example.com'],
],
]);
}
public function test_save_notification_note_returns_service_response(): void
{
$service = $this->mockService();
$payload = [
'student_id' => 10,
'code' => 'ABS_1',
'note' => 'Called parent',
'incident_date' => '2026-03-01',
];
$service->shouldReceive('saveNotificationNote')
->once()
->with($payload)
->andReturn([
'success' => true,
'message' => 'Note saved.',
]);
$response = $this->postJson('/api/attendance-tracking/save-notification-note', $payload);
$response->assertOk()
->assertJson([
'success' => true,
'message' => 'Note saved.',
]);
}
public function test_save_notification_note_validates_payload(): void
{
$response = $this->postJson('/api/attendance-tracking/save-notification-note', [
'student_id' => 0,
'code' => '',
'note' => '',
]);
$response->assertStatus(422)
->assertJsonValidationErrors(['student_id', 'code', 'note']);
}
}
@@ -0,0 +1,237 @@
<?php
namespace Tests\Feature\Api;
use App\Models\User;
use App\Services\Badges\BadgeFormDataService;
use App\Services\Badges\BadgePdfService;
use App\Services\Badges\BadgePrintLogService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Laravel\Sanctum\Sanctum;
use Mockery;
use Tests\TestCase;
class BadgeControllerTest extends TestCase
{
use RefreshDatabase;
protected function authenticate(): User
{
$user = User::factory()->create();
Sanctum::actingAs($user);
return $user;
}
protected function tearDown(): void
{
Mockery::close();
parent::tearDown();
}
public function test_form_data_returns_success_response(): void
{
$this->authenticate();
$mock = Mockery::mock(BadgeFormDataService::class);
$mock->shouldReceive('build')
->once()
->with('2025-2026', [10, 20], 'teacher')
->andReturn([
'users' => [],
'schoolYears' => ['2025-2026'],
'selectedYear' => '2025-2026',
'selectedUserIds' => [10, 20],
'rolesTabs' => ['teacher' => 'Teachers'],
'active_role' => 'teacher',
]);
$this->app->instance(BadgeFormDataService::class, $mock);
$response = $this->getJson('/api/badges/form-data?school_year=2025-2026&user_ids=10,20&active_role=teacher');
$response->assertOk()
->assertJson([
'ok' => true,
'data' => [
'selectedYear' => '2025-2026',
'selectedUserIds' => [10, 20],
'active_role' => 'teacher',
],
]);
}
public function test_print_status_returns_success_response(): void
{
$this->authenticate();
$mock = Mockery::mock(BadgePrintLogService::class);
$mock->shouldReceive('getStatus')
->once()
->with([5, 7], '2025-2026')
->andReturn([
5 => [
'count' => 2,
'last_printed_at' => '2026-03-06 12:00:00',
'last_printed_by' => 1,
],
]);
$this->app->instance(BadgePrintLogService::class, $mock);
$response = $this->getJson('/api/badges/print-status?user_ids=5,7&school_year=2025-2026');
$response->assertOk()
->assertJson([
'ok' => true,
'data' => [
'5' => [
'count' => 2,
'last_printed_at' => '2026-03-06 12:00:00',
'last_printed_by' => 1,
],
],
]);
}
public function test_print_status_returns_500_when_service_throws(): void
{
$this->authenticate();
$mock = Mockery::mock(BadgePrintLogService::class);
$mock->shouldReceive('getStatus')
->once()
->andThrow(new \RuntimeException('db failed'));
$this->app->instance(BadgePrintLogService::class, $mock);
$response = $this->getJson('/api/badges/print-status?user_ids=5,7');
$response->assertStatus(500)
->assertJson([
'ok' => false,
'error' => 'Failed to query status',
]);
}
public function test_log_print_returns_success_response(): void
{
$authUser = $this->authenticate();
$mock = Mockery::mock(BadgePrintLogService::class);
$mock->shouldReceive('log')
->once()
->with(
[10, 20],
$authUser->id,
'2025-2026',
['10' => 'Teacher'],
['10' => '3-A']
)
->andReturn(2);
$this->app->instance(BadgePrintLogService::class, $mock);
$response = $this->postJson('/api/badges/log-print', [
'user_ids' => [10, 20],
'school_year' => '2025-2026',
'roles' => ['10' => 'Teacher'],
'classes' => ['10' => '3-A'],
]);
$response->assertOk()
->assertJson([
'ok' => true,
'inserted' => 2,
]);
}
public function test_log_print_returns_500_when_service_throws(): void
{
$this->authenticate();
$mock = Mockery::mock(BadgePrintLogService::class);
$mock->shouldReceive('log')
->once()
->andThrow(new \RuntimeException('insert failed'));
$this->app->instance(BadgePrintLogService::class, $mock);
$response = $this->postJson('/api/badges/log-print', [
'user_ids' => [10],
]);
$response->assertStatus(500)
->assertJson([
'ok' => false,
'error' => 'insert failed',
]);
}
public function test_generate_pdf_returns_pdf_response(): void
{
$authUser = $this->authenticate();
$mock = Mockery::mock(BadgePdfService::class);
$mock->shouldReceive('generate')
->once()
->with(
[10, 20],
'2025-2026',
['10' => 'Teacher'],
['10' => '3-A'],
$authUser->id
)
->andReturn(response('fake-pdf-content', 200, [
'Content-Type' => 'application/pdf',
'Content-Disposition' => 'inline; filename="Staff_Badges.pdf"',
]));
$this->app->instance(BadgePdfService::class, $mock);
$response = $this->post('/api/badges/pdf', [
'user_ids' => [10, 20],
'school_year' => '2025-2026',
'roles' => ['10' => 'Teacher'],
'classes' => ['10' => '3-A'],
]);
$response->assertOk();
$response->assertHeader('Content-Type', 'application/pdf');
$this->assertStringContainsString(
'inline; filename="Staff_Badges.pdf"',
$response->headers->get('Content-Disposition')
);
$this->assertSame('fake-pdf-content', $response->getContent());
}
public function test_generate_pdf_validates_required_user_ids(): void
{
$this->authenticate();
$response = $this->postJson('/api/badges/pdf', [
'school_year' => '2025-2026',
]);
$response->assertStatus(422)
->assertJsonValidationErrors(['user_ids']);
}
public function test_log_print_validates_required_user_ids(): void
{
$this->authenticate();
$response = $this->postJson('/api/badges/log-print', []);
$response->assertStatus(422)
->assertJsonValidationErrors(['user_ids']);
}
public function test_endpoints_require_authentication(): void
{
$this->getJson('/api/badges/form-data')->assertStatus(401);
$this->getJson('/api/badges/print-status')->assertStatus(401);
$this->postJson('/api/badges/log-print', ['user_ids' => [1]])->assertStatus(401);
$this->postJson('/api/badges/pdf', ['user_ids' => [1]])->assertStatus(401);
}
}
-19
View File
@@ -1,19 +0,0 @@
<?php
namespace Tests\Feature;
// use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class ExampleTest extends TestCase
{
/**
* A basic test example.
*/
public function test_the_application_returns_a_successful_response(): void
{
$response = $this->get('/');
$response->assertStatus(200);
}
}
@@ -2,7 +2,7 @@
namespace Tests\Unit\Http\Controllers\Api;
use App\Http\Controllers\Api\AttendanceCommentTemplateController;
use App\Http\Controllers\Api\Attendance\AttendanceCommentTemplateController;
use App\Http\Requests\AttendanceCommentTemplate\StoreAttendanceCommentTemplateRequest;
use App\Http\Requests\AttendanceCommentTemplate\UpdateAttendanceCommentTemplateRequest;
use App\Services\AttendanceCommentTemplateService;
+3 -3
View File
@@ -2,14 +2,14 @@
namespace Tests\Unit\Models;
use App\Models\Settings;
use App\Models\Setting;
use PHPUnit\Framework\TestCase;
class SettingsTest extends TestCase
{
public function test_model_metadata_is_converted(): void
{
$model = new Settings();
$model = new Setting();
$this->assertSame('settings', $model->getTable());
$this->assertSame('id', $model->getKeyName());
@@ -19,6 +19,6 @@ class SettingsTest extends TestCase
public function test_model_class_loads(): void
{
$this->assertInstanceOf(Settings::class, new Settings());
$this->assertInstanceOf(Setting::class, new Setting());
}
}
@@ -0,0 +1,121 @@
<?php
namespace Tests\Unit\Services\Administrator;
use App\Models\AdminNotificationSubject;
use App\Services\Administrator\AdminNotificationSubjectService;
use App\Services\Administrator\AdminNotificationUserService;
use Illuminate\Support\Facades\DB;
use Mockery;
use Tests\TestCase;
class AdminNotificationSubjectServiceTest extends TestCase
{
protected function tearDown(): void
{
Mockery::close();
parent::tearDown();
}
public function test_alerts_data_returns_expected_payload_when_table_exists(): void
{
DB::shouldReceive('getSchemaBuilder->hasTable')
->once()
->with('admin_notification_subjects')
->andReturn(true);
$userService = Mockery::mock(AdminNotificationUserService::class);
$userService->shouldReceive('fetchAdminNotificationUsers')
->once()
->andReturn([
['id' => 1, 'firstname' => 'Admin', 'lastname' => 'One', 'email' => 'a1@test.com'],
['id' => 2, 'firstname' => 'Admin', 'lastname' => 'Two', 'email' => 'a2@test.com'],
]);
$row1 = (object) ['id' => 10, 'admin_id' => 1, 'subject' => 'academics'];
$row2 = (object) ['id' => 11, 'admin_id' => 1, 'subject' => 'finance'];
$row3 = (object) ['id' => 12, 'admin_id' => 2, 'subject' => 'general'];
AdminNotificationSubject::shouldReceive('query->select->whereIn->get')
->once()
->andReturn(collect([$row1, $row2, $row3]));
$service = new AdminNotificationSubjectService($userService);
$result = $service->alertsData();
$this->assertTrue($result['tableReady']);
$this->assertCount(2, $result['admins']);
$this->assertArrayHasKey('academics', $result['subjects']);
$this->assertTrue($result['assignedSubjects'][1]['academics']);
$this->assertTrue($result['assignedSubjects'][1]['finance']);
$this->assertTrue($result['assignedSubjects'][2]['general']);
}
public function test_save_returns_error_when_table_missing(): void
{
DB::shouldReceive('getSchemaBuilder->hasTable')
->once()
->with('admin_notification_subjects')
->andReturn(false);
$userService = Mockery::mock(AdminNotificationUserService::class);
$service = new AdminNotificationSubjectService($userService);
$result = $service->save([
1 => ['academics'],
]);
$this->assertFalse($result['success']);
$this->assertSame(422, $result['status']);
}
public function test_save_inserts_and_deletes_subjects(): void
{
DB::shouldReceive('getSchemaBuilder->hasTable')
->once()
->with('admin_notification_subjects')
->andReturn(true);
$userService = Mockery::mock(AdminNotificationUserService::class);
$userService->shouldReceive('fetchAdminNotificationUsers')
->once()
->andReturn([
['id' => 1, 'firstname' => 'Admin', 'lastname' => 'One', 'email' => 'a1@test.com'],
['id' => 2, 'firstname' => 'Admin', 'lastname' => 'Two', 'email' => 'a2@test.com'],
]);
$existing1 = (object) ['id' => 101, 'admin_id' => 1, 'subject' => 'academics'];
$existing2 = (object) ['id' => 102, 'admin_id' => 1, 'subject' => 'finance'];
$existing3 = (object) ['id' => 103, 'admin_id' => 2, 'subject' => 'general'];
AdminNotificationSubject::shouldReceive('query->select->whereIn->get')
->once()
->andReturn(collect([$existing1, $existing2, $existing3]));
AdminNotificationSubject::shouldReceive('query->where->whereIn->delete')
->once()
->andReturn(1);
AdminNotificationSubject::shouldReceive('insert')
->once()
->with(Mockery::on(function ($batch) {
return count($batch) === 2
&& $batch[0]['admin_id'] === 1
&& $batch[0]['subject'] === 'general'
&& $batch[1]['admin_id'] === 2
&& $batch[1]['subject'] === 'attendance';
}))
->andReturn(true);
$service = new AdminNotificationSubjectService($userService);
$result = $service->save([
1 => ['academics', 'general'],
2 => ['general', 'attendance'],
]);
$this->assertTrue($result['success']);
$this->assertSame(200, $result['status']);
}
}
@@ -0,0 +1,112 @@
<?php
namespace Tests\Unit\Services\Administrator;
use App\Models\AdminNotificationSubject;
use App\Services\Administrator\AdminNotificationUserService;
use App\Services\Administrator\AdminPrintRecipientService;
use Illuminate\Support\Facades\DB;
use Mockery;
use Tests\TestCase;
class AdminPrintRecipientServiceTest extends TestCase
{
protected function tearDown(): void
{
Mockery::close();
parent::tearDown();
}
public function test_data_returns_assigned_admins(): void
{
DB::shouldReceive('getSchemaBuilder->hasTable')
->once()
->with('admin_notification_subjects')
->andReturn(true);
$userService = Mockery::mock(AdminNotificationUserService::class);
$userService->shouldReceive('fetchAdminNotificationUsers')
->once()
->andReturn([
['id' => 1, 'firstname' => 'A', 'lastname' => 'One', 'email' => 'a1@test.com'],
['id' => 2, 'firstname' => 'A', 'lastname' => 'Two', 'email' => 'a2@test.com'],
]);
$row1 = (object) ['admin_id' => 1];
$row2 = (object) ['admin_id' => 2];
AdminNotificationSubject::shouldReceive('query->select->where->whereIn->get')
->once()
->andReturn(collect([$row1, $row2]));
$service = new AdminPrintRecipientService($userService);
$result = $service->data();
$this->assertTrue($result['tableReady']);
$this->assertTrue($result['assigned'][1]);
$this->assertTrue($result['assigned'][2]);
}
public function test_save_returns_error_when_table_missing(): void
{
DB::shouldReceive('getSchemaBuilder->hasTable')
->once()
->with('admin_notification_subjects')
->andReturn(false);
$userService = Mockery::mock(AdminNotificationUserService::class);
$service = new AdminPrintRecipientService($userService);
$result = $service->save([
1 => 1,
]);
$this->assertFalse($result['success']);
$this->assertSame(422, $result['status']);
}
public function test_save_updates_print_recipients(): void
{
DB::shouldReceive('getSchemaBuilder->hasTable')
->once()
->with('admin_notification_subjects')
->andReturn(true);
$userService = Mockery::mock(AdminNotificationUserService::class);
$userService->shouldReceive('fetchAdminNotificationUsers')
->once()
->andReturn([
['id' => 1],
['id' => 2],
['id' => 3],
]);
AdminNotificationSubject::shouldReceive('query->where->whereIn->pluck')
->once()
->andReturn(collect([1, 2]));
AdminNotificationSubject::shouldReceive('query->where->whereIn->delete')
->once()
->andReturn(1);
AdminNotificationSubject::shouldReceive('insert')
->once()
->with(Mockery::on(function ($batch) {
return count($batch) === 1
&& $batch[0]['admin_id'] === 3
&& $batch[0]['subject'] === 'print_requests';
}))
->andReturn(true);
$service = new AdminPrintRecipientService($userService);
$result = $service->save([
1 => 1,
3 => 1,
]);
$this->assertTrue($result['success']);
$this->assertSame(200, $result['status']);
}
}
@@ -0,0 +1,270 @@
<?php
namespace Tests\Unit\Services\Administrator;
use App\Models\ClassSection;
use App\Models\Configuration;
use App\Models\Enrollment;
use App\Models\Student;
use App\Models\StudentClass;
use App\Services\Administrator\AdministratorEnrollmentQueryService;
use App\Services\Administrator\AdministratorSharedService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Mockery;
use Tests\TestCase;
class AdministratorEnrollmentQueryServiceTest extends TestCase
{
use RefreshDatabase;
protected function tearDown(): void
{
Mockery::close();
parent::tearDown();
}
protected function makeSharedService(string $schoolYear = '2025-2026', string $semester = 'Fall'): AdministratorSharedService
{
$configuration = Mockery::mock(Configuration::class);
$configuration->shouldReceive('getConfig')
->with('school_year')
->andReturn($schoolYear);
$configuration->shouldReceive('getConfig')
->with('semester')
->andReturn($semester);
return new AdministratorSharedService($configuration);
}
public function test_enrollment_withdrawal_data_returns_students_classes_and_school_years(): void
{
DB::table('enrollments')->insert([
[
'student_id' => 1,
'parent_id' => 100,
'school_year' => '2025-2026',
'semester' => 'Fall',
'enrollment_status' => 'enrolled',
'admission_status' => 'accepted',
'is_withdrawn' => 0,
'created_at' => now(),
'updated_at' => now(),
],
[
'student_id' => 2,
'parent_id' => 101,
'school_year' => '2024-2025',
'semester' => 'Fall',
'enrollment_status' => 'withdrawn',
'admission_status' => 'accepted',
'is_withdrawn' => 1,
'created_at' => now(),
'updated_at' => now(),
],
]);
$studentModel = Mockery::mock(Student::class);
$studentModel->shouldReceive('getStudentsWithClassAndEnrollment')
->once()
->andReturn([
[
'id' => 1,
'firstname' => 'Ali',
'lastname' => 'Ben',
'parent_id' => 100,
'parent_firstname' => 'Parent',
'parent_lastname' => 'One',
'is_new' => 1,
'registration_date' => '2025-09-10 08:00:00',
'admission_status' => 'accepted',
],
[
'id' => 2,
'firstname' => 'Sara',
'lastname' => 'Ali',
'parent_id' => 101,
'parent_firstname' => 'Parent',
'parent_lastname' => 'Two',
'is_new' => 0,
'registration_date' => '2024-09-10 08:00:00',
'admission_status' => 'accepted',
],
]);
$studentClassModel = Mockery::mock(StudentClass::class);
$studentClassModel->shouldReceive('getClassSectionsByStudentId')
->with(1, '2025-2026')
->andReturn('Grade 5A');
$studentClassModel->shouldReceive('getClassSectionsByStudentId')
->with(2, '2025-2026')
->andReturn('');
$enrollmentModel = Mockery::mock(Enrollment::class);
$enrollmentModel->shouldReceive('getEnrollmentStatus')
->with(1, '2025-2026')
->andReturn('enrolled');
$enrollmentModel->shouldReceive('getEnrollmentStatus')
->with(2, '2025-2026')
->andReturn('');
ClassSection::unguard();
ClassSection::create([
'class_section_id' => 10,
'class_section_name' => 'Grade 5A',
'school_year' => '2025-2026',
'semester' => 'Fall',
]);
$service = new AdministratorEnrollmentQueryService(
$this->makeSharedService(),
$studentModel,
$studentClassModel,
$enrollmentModel,
new ClassSection()
);
$result = $service->enrollmentWithdrawalData('2025-2026');
$this->assertSame('2025-2026', $result['school_year']);
$this->assertSame('Fall', $result['semester']);
$this->assertNotEmpty($result['schoolYears']);
$this->assertCount(2, $result['students']);
$this->assertCount(1, $result['classes']);
$student1 = collect($result['students'])->firstWhere('student_id', 1);
$student2 = collect($result['students'])->firstWhere('student_id', 2);
$this->assertSame('Yes', $student1['new_student']);
$this->assertSame('Grade 5A', $student1['class_section']);
$this->assertSame('enrolled', $student1['enrollment_status']);
$this->assertSame('2025-09-10', $student1['registration_date_order']);
$this->assertSame('No', $student2['new_student']);
$this->assertSame('Class not Assigned', $student2['class_section']);
}
public function test_enrollment_withdrawal_data_marks_removed_previous_year(): void
{
DB::table('enrollments')->insert([
[
'student_id' => 5,
'parent_id' => 200,
'school_year' => '2024-2025',
'semester' => 'Fall',
'enrollment_status' => 'withdrawn',
'admission_status' => 'accepted',
'is_withdrawn' => 1,
'created_at' => now(),
'updated_at' => now(),
],
]);
$studentModel = Mockery::mock(Student::class);
$studentModel->shouldReceive('getStudentsWithClassAndEnrollment')
->once()
->andReturn([
[
'id' => 5,
'firstname' => 'Mina',
'lastname' => 'K',
'parent_id' => 200,
'parent_firstname' => 'Parent',
'parent_lastname' => 'Three',
'is_new' => 0,
'registration_date' => '2024-08-01 00:00:00',
'admission_status' => 'accepted',
],
]);
$studentClassModel = Mockery::mock(StudentClass::class);
$studentClassModel->shouldReceive('getClassSectionsByStudentId')
->with(5, '2025-2026')
->andReturn('Grade 6A');
$enrollmentModel = Mockery::mock(Enrollment::class);
$enrollmentModel->shouldReceive('getEnrollmentStatus')
->with(5, '2025-2026')
->andReturn('payment pending');
$service = new AdministratorEnrollmentQueryService(
$this->makeSharedService('2025-2026', 'Fall'),
$studentModel,
$studentClassModel,
$enrollmentModel,
new ClassSection()
);
$result = $service->enrollmentWithdrawalData('2025-2026');
$student = $result['students'][0];
$this->assertSame('Yes', $student['removed_previous_year']);
$this->assertSame('payment pending', $student['enrollment_status']);
}
public function test_new_students_data_returns_transformed_rows(): void
{
$studentModel = Mockery::mock(Student::class);
$studentModel->shouldReceive('getStudentsWithParentsAndEmergency')
->once()
->with('2025-2026')
->andReturn([
[
'id' => 9,
'firstname' => 'Adam',
'lastname' => 'Y',
'is_new' => 1,
'registration_date' => '2025-10-01 10:00:00',
],
[
'id' => 10,
'firstname' => 'Eve',
'lastname' => 'Z',
'is_new' => 0,
'registration_date' => null,
],
]);
$studentClassModel = Mockery::mock(StudentClass::class);
$studentClassModel->shouldReceive('getClassSectionsByStudentId')
->with(9, '2025-2026')
->andReturn('Grade 1A');
$studentClassModel->shouldReceive('getClassSectionsByStudentId')
->with(10, '2025-2026')
->andReturn('');
$enrollmentModel = Mockery::mock(Enrollment::class);
$enrollmentModel->shouldReceive('getEnrollmentStatus')
->with(9, '2025-2026')
->andReturn('enrolled');
$enrollmentModel->shouldReceive('getEnrollmentStatus')
->with(10, '2025-2026')
->andReturn('waitlist');
$service = new AdministratorEnrollmentQueryService(
$this->makeSharedService(),
$studentModel,
$studentClassModel,
$enrollmentModel,
new ClassSection()
);
$result = $service->newStudentsData();
$this->assertSame(2, $result['total_new']);
$first = $result['new_students'][0];
$second = $result['new_students'][1];
$this->assertSame('Yes', $first['new_student']);
$this->assertSame('Grade 1A', $first['class_section']);
$this->assertSame('enrolled', $first['enrollment_status']);
$this->assertSame('contact_9', $first['modalIdContact']);
$this->assertSame('2025-10-01', $first['registration_date']);
$this->assertSame('No', $second['new_student']);
$this->assertSame('Class not Assigned', $second['class_section']);
$this->assertSame('waitlist', $second['enrollment_status']);
}
}
@@ -0,0 +1,237 @@
<?php
namespace Tests\Unit\Services\Administrator;
use App\Models\Student;
use App\Models\User;
use App\Services\Administrator\AdministratorEnrollmentEventService;
use App\Services\Administrator\AdministratorEnrollmentRefundService;
use App\Services\Administrator\AdministratorEnrollmentStatusService;
use App\Services\Administrator\AdministratorSharedService;
use Illuminate\Support\Facades\DB;
use Mockery;
use Tests\TestCase;
class AdministratorEnrollmentStatusServiceTest extends TestCase
{
protected function tearDown(): void
{
Mockery::close();
parent::tearDown();
}
public function test_update_statuses_returns_validation_error_when_empty(): void
{
$shared = Mockery::mock(AdministratorSharedService::class);
$studentModel = Mockery::mock(Student::class);
$userModel = Mockery::mock(User::class);
$refundService = Mockery::mock(AdministratorEnrollmentRefundService::class);
$eventService = Mockery::mock(AdministratorEnrollmentEventService::class);
$service = new AdministratorEnrollmentStatusService(
$shared,
$studentModel,
$userModel,
$refundService,
$eventService
);
$result = $service->updateStatuses([], 99);
$this->assertFalse($result['success']);
$this->assertSame(422, $result['status']);
}
public function test_update_statuses_creates_new_enrollment_and_dispatches_events(): void
{
$shared = Mockery::mock(AdministratorSharedService::class);
$shared->shouldReceive('getSchoolYear')->andReturn('2025-2026');
$shared->shouldReceive('getSemester')->andReturn('Fall');
$studentModel = Mockery::mock(Student::class);
$studentModel->shouldReceive('find')
->once()
->with(10)
->andReturn([
'id' => 10,
'firstname' => 'Ali',
'lastname' => 'Ben',
'parent_id' => 20,
]);
$userModel = Mockery::mock(User::class);
$userModel->shouldReceive('find')
->once()
->with(20)
->andReturn([
'id' => 20,
'firstname' => 'Parent',
'lastname' => 'One',
'email' => 'parent@test.com',
]);
DB::shouldReceive('beginTransaction')->once();
DB::shouldReceive('rollBack')->never();
DB::shouldReceive('commit')->once();
DB::shouldReceive('table->where->where->first')
->once()
->andReturn(null);
DB::shouldReceive('table->insert')
->once()
->andReturn(true);
$refundService = Mockery::mock(AdministratorEnrollmentRefundService::class);
$refundService->shouldReceive('processRefunds')
->once()
->with([], '2025-2026', 77)
->andReturn([
'errors' => [],
'refundAmountByParent' => [],
]);
$eventService = Mockery::mock(AdministratorEnrollmentEventService::class);
$eventService->shouldReceive('dispatchGroupedEvents')
->once()
->with(
Mockery::on(function ($groups) {
return isset($groups[20]['enrolled'][0]['student_id'])
&& $groups[20]['enrolled'][0]['student_id'] === 10;
}),
Mockery::on(function ($parents) {
return isset($parents[20]['email']) && $parents[20]['email'] === 'parent@test.com';
}),
[],
'2025-2026'
);
$service = new AdministratorEnrollmentStatusService(
$shared,
$studentModel,
$userModel,
$refundService,
$eventService
);
$result = $service->updateStatuses([
10 => 'enrolled',
], 77);
$this->assertTrue($result['success']);
$this->assertSame(200, $result['status']);
}
public function test_update_statuses_updates_existing_enrollment_and_processes_refund(): void
{
$shared = Mockery::mock(AdministratorSharedService::class);
$shared->shouldReceive('getSchoolYear')->andReturn('2025-2026');
$shared->shouldReceive('getSemester')->andReturn('Fall');
$studentModel = Mockery::mock(Student::class);
$studentModel->shouldReceive('find')
->once()
->with(10)
->andReturn([
'id' => 10,
'firstname' => 'Sara',
'lastname' => 'Ali',
'parent_id' => 20,
]);
$userModel = Mockery::mock(User::class);
$userModel->shouldReceive('find')
->once()
->with(20)
->andReturn([
'id' => 20,
'firstname' => 'Parent',
'lastname' => 'Two',
'email' => 'p2@test.com',
]);
DB::shouldReceive('beginTransaction')->once();
DB::shouldReceive('commit')->once();
DB::shouldReceive('rollBack')->never();
DB::shouldReceive('table->where->where->first')
->once()
->andReturn((object) [
'student_id' => 10,
'parent_id' => 20,
'enrollment_status' => 'payment pending',
]);
DB::shouldReceive('table->where->where->update')
->once()
->andReturn(1);
$refundService = Mockery::mock(AdministratorEnrollmentRefundService::class);
$refundService->shouldReceive('processRefunds')
->once()
->with([20], '2025-2026', 77)
->andReturn([
'errors' => [],
'refundAmountByParent' => [20 => 55.50],
]);
$eventService = Mockery::mock(AdministratorEnrollmentEventService::class);
$eventService->shouldReceive('dispatchGroupedEvents')
->once();
$service = new AdministratorEnrollmentStatusService(
$shared,
$studentModel,
$userModel,
$refundService,
$eventService
);
$result = $service->updateStatuses([
10 => 'refund pending',
], 77);
$this->assertTrue($result['success']);
$this->assertSame(200, $result['status']);
}
public function test_update_statuses_returns_partial_success_when_invalid_status_present(): void
{
$shared = Mockery::mock(AdministratorSharedService::class);
$shared->shouldReceive('getSchoolYear')->andReturn('2025-2026');
$shared->shouldReceive('getSemester')->andReturn('Fall');
DB::shouldReceive('beginTransaction')->once();
DB::shouldReceive('commit')->once();
$studentModel = Mockery::mock(Student::class);
$userModel = Mockery::mock(User::class);
$refundService = Mockery::mock(AdministratorEnrollmentRefundService::class);
$refundService->shouldReceive('processRefunds')
->once()
->andReturn([
'errors' => [],
'refundAmountByParent' => [],
]);
$eventService = Mockery::mock(AdministratorEnrollmentEventService::class);
$eventService->shouldReceive('dispatchGroupedEvents')->once();
$service = new AdministratorEnrollmentStatusService(
$shared,
$studentModel,
$userModel,
$refundService,
$eventService
);
$result = $service->updateStatuses([
10 => 'not-valid',
], 77);
$this->assertFalse($result['success']);
$this->assertSame(207, $result['status']);
$this->assertStringContainsString('Invalid enrollment status', $result['message']);
}
}
@@ -0,0 +1,173 @@
<?php
namespace Tests\Unit\Services\Administrator;
use App\Models\ClassSection;
use App\Models\TeacherSubmissionNotificationHistory;
use App\Models\User;
use App\Services\Administrator\AdministratorSharedService;
use App\Services\Administrator\TeacherSubmissionNotificationService;
use App\Services\Administrator\TeacherSubmissionSupportService;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Mail;
use Mockery;
use Tests\TestCase;
class TeacherSubmissionNotificationServiceTest extends TestCase
{
protected function tearDown(): void
{
Mockery::close();
parent::tearDown();
}
public function test_send_returns_validation_style_error_when_notify_missing(): void
{
$shared = Mockery::mock(AdministratorSharedService::class);
$support = new TeacherSubmissionSupportService();
$service = new TeacherSubmissionNotificationService($shared, $support);
$request = new Request([]);
$result = $service->send($request, 1);
$this->assertFalse($result['success']);
$this->assertSame(422, $result['status']);
}
public function test_send_sends_email_and_logs_history(): void
{
$shared = Mockery::mock(AdministratorSharedService::class);
$shared->shouldReceive('getSchoolYear')->andReturn('2025-2026');
$shared->shouldReceive('getSemester')->andReturn('Fall');
$support = new TeacherSubmissionSupportService();
$request = new Request([
'notify' => [
1 => [
5 => 1,
],
],
'missing_items' => [
1 => [
5 => base64_encode(json_encode(['midterm scores', 'attendance'])),
],
],
]);
$section = new ClassSection();
$section->class_section_id = 1;
$section->class_section_name = 'Grade 5A';
$teacher = new User();
$teacher->id = 5;
$teacher->firstname = 'John';
$teacher->lastname = 'Teacher';
$teacher->email = 'teacher@test.com';
$admin = new User();
$admin->id = 1;
$admin->firstname = 'Admin';
$admin->lastname = 'User';
ClassSection::shouldReceive('query->select->whereIn->get->keyBy')
->once()
->andReturn(collect([1 => $section]));
User::shouldReceive('query->select->whereIn->get->keyBy')
->once()
->andReturn(collect([5 => $teacher]));
User::shouldReceive('find')
->once()
->with(1)
->andReturn($admin);
Mail::fake();
TeacherSubmissionNotificationHistory::shouldReceive('create')
->once()
->with(Mockery::on(function ($data) {
return $data['teacher_id'] === 5
&& $data['class_section_id'] === 1
&& $data['admin_id'] === 1
&& $data['notification_category'] === 'teacher_submissions'
&& $data['status'] === 'sent';
}))
->andReturnTrue();
$service = new TeacherSubmissionNotificationService($shared, $support);
$result = $service->send($request, 1);
$this->assertTrue($result['success']);
$this->assertSame(200, $result['status']);
$this->assertSame(1, $result['sent']);
$this->assertSame(0, $result['failed']);
Mail::assertSentCount(1);
}
public function test_send_marks_failed_when_teacher_email_invalid(): void
{
$shared = Mockery::mock(AdministratorSharedService::class);
$shared->shouldReceive('getSchoolYear')->andReturn('2025-2026');
$shared->shouldReceive('getSemester')->andReturn('Fall');
$support = new TeacherSubmissionSupportService();
$request = new Request([
'notify' => [
1 => [
5 => 1,
],
],
]);
$section = new ClassSection();
$section->class_section_id = 1;
$section->class_section_name = 'Grade 5A';
$teacher = new User();
$teacher->id = 5;
$teacher->firstname = 'John';
$teacher->lastname = 'Teacher';
$teacher->email = 'invalid-email';
$admin = new User();
$admin->id = 1;
$admin->firstname = 'Admin';
$admin->lastname = 'User';
ClassSection::shouldReceive('query->select->whereIn->get->keyBy')
->once()
->andReturn(collect([1 => $section]));
User::shouldReceive('query->select->whereIn->get->keyBy')
->once()
->andReturn(collect([5 => $teacher]));
User::shouldReceive('find')
->once()
->with(1)
->andReturn($admin);
TeacherSubmissionNotificationHistory::shouldReceive('create')
->once()
->with(Mockery::on(function ($data) {
return $data['status'] === 'failed';
}))
->andReturnTrue();
$service = new TeacherSubmissionNotificationService($shared, $support);
$result = $service->send($request, 1);
$this->assertFalse($result['success']);
$this->assertSame(207, $result['status']);
$this->assertSame(0, $result['sent']);
$this->assertSame(1, $result['failed']);
}
}
@@ -0,0 +1,316 @@
<?php
namespace Tests\Unit\Services\Administrator;
use App\Models\AttendanceDay;
use App\Models\Configuration;
use App\Models\ScoreComment;
use App\Models\SemesterScore;
use App\Models\TeacherSubmissionNotificationHistory;
use App\Services\Administrator\AdministratorSharedService;
use App\Services\Administrator\TeacherSubmissionReportService;
use App\Services\Administrator\TeacherSubmissionSupportService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Mockery;
use Tests\TestCase;
class TeacherSubmissionReportServiceTest extends TestCase
{
use RefreshDatabase;
protected function tearDown(): void
{
Mockery::close();
parent::tearDown();
}
protected function makeSharedService(string $schoolYear = '2025-2026', string $semester = 'Fall'): AdministratorSharedService
{
$configuration = Mockery::mock(Configuration::class);
$configuration->shouldReceive('getConfig')
->with('school_year')
->andReturn($schoolYear);
$configuration->shouldReceive('getConfig')
->with('semester')
->andReturn($semester);
return new AdministratorSharedService($configuration);
}
public function test_report_returns_rows_summary_and_history(): void
{
DB::table('teacher_class')->insert([
[
'class_section_id' => 10,
'teacher_id' => 100,
'position' => 'main',
'school_year' => '2025-2026',
'semester' => 'Fall',
],
[
'class_section_id' => 10,
'teacher_id' => 101,
'position' => 'ta',
'school_year' => '2025-2026',
'semester' => 'Fall',
],
]);
DB::table('classSection')->insert([
[
'class_section_id' => 10,
'class_section_name' => 'Grade 5A',
],
]);
DB::table('users')->insert([
[
'id' => 100,
'firstname' => 'Main',
'lastname' => 'Teacher',
'email' => 'main@test.com',
],
[
'id' => 101,
'firstname' => 'TA',
'lastname' => 'Teacher',
'email' => 'ta@test.com',
],
[
'id' => 900,
'firstname' => 'Admin',
'lastname' => 'One',
'email' => 'admin@test.com',
],
]);
DB::table('student_class')->insert([
[
'student_id' => 1,
'class_section_id' => 10,
'semester' => 'Fall',
'school_year' => '2025-2026',
],
[
'student_id' => 2,
'class_section_id' => 10,
'semester' => 'Fall',
'school_year' => '2025-2026',
],
]);
SemesterScore::unguard();
SemesterScore::create([
'student_id' => 1,
'class_section_id' => 10,
'semester' => 'Fall',
'school_year' => '2025-2026',
'midterm_exam_score' => '88',
'participation_score' => '10',
]);
SemesterScore::create([
'student_id' => 2,
'class_section_id' => 10,
'semester' => 'Fall',
'school_year' => '2025-2026',
'midterm_exam_score' => '',
'participation_score' => '9',
]);
ScoreComment::unguard();
ScoreComment::create([
'student_id' => 1,
'semester' => 'Fall',
'school_year' => '2025-2026',
'score_type' => 'midterm',
'comment' => 'Good progress',
]);
ScoreComment::create([
'student_id' => 1,
'semester' => 'Fall',
'school_year' => '2025-2026',
'score_type' => 'ptap',
'comment' => 'Great effort',
]);
AttendanceDay::unguard();
AttendanceDay::create([
'class_section_id' => 10,
'semester' => 'Fall',
'school_year' => '2025-2026',
'date' => now()->format('Y-m-d'),
'status' => 'submitted',
]);
TeacherSubmissionNotificationHistory::unguard();
TeacherSubmissionNotificationHistory::create([
'teacher_id' => 100,
'class_section_id' => 10,
'admin_id' => 900,
'notification_category' => 'teacher_submissions',
'message' => 'Reminder sent',
'status' => 'sent',
'school_year' => '2025-2026',
'semester' => 'Fall',
'sent_at' => now(),
]);
$service = new TeacherSubmissionReportService(
$this->makeSharedService(),
new TeacherSubmissionSupportService()
);
$result = $service->report();
$this->assertSame('Fall', $result['semester']);
$this->assertSame('2025-2026', $result['schoolYear']);
$this->assertCount(1, $result['rows']);
$row = $result['rows'][0];
$this->assertSame('Grade 5A', $row['class_section']);
$this->assertSame(10, $row['class_section_id']);
$this->assertCount(2, $row['teachers']);
$this->assertSame('Main: Main Teacher', $row['teachers'][0]['label']);
$this->assertSame('TA: TA Teacher', $row['teachers'][1]['label']);
$this->assertSame('Missing', $row['midterm_score_status']['label']);
$this->assertSame('1/2', $row['midterm_score_status']['detail']);
$this->assertSame('Missing', $row['midterm_comment_status']['label']);
$this->assertSame('1/2', $row['midterm_comment_status']['detail']);
$this->assertSame('Submitted', $row['participation_status']['label']);
$this->assertSame('2/2', $row['participation_status']['detail']);
$this->assertSame('Missing', $row['ptap_comment_status']['label']);
$this->assertSame('1/2', $row['ptap_comment_status']['detail']);
$this->assertSame('Submitted', $row['attendance_status']['label']);
$this->assertSame([
'midterm scores',
'midterm comments',
'PTAP comments',
], $row['missing_items']);
$this->assertSame(2, $row['student_count']);
$this->assertArrayHasKey(10, $result['notificationHistory']);
$this->assertArrayHasKey(100, $result['notificationHistory'][10]);
$this->assertSame('Admin One', $result['notificationHistory'][10][100][0]['admin_name']);
$this->assertSame(5, $result['summary']['total_items']);
$this->assertSame(3, $result['summary']['missing_items']);
$this->assertSame(2, $result['summary']['submitted_items']);
$this->assertSame(40, $result['summary']['submission_percentage']);
}
public function test_report_returns_no_students_status_when_section_has_no_students(): void
{
DB::table('teacher_class')->insert([
[
'class_section_id' => 11,
'teacher_id' => 200,
'position' => 'main',
'school_year' => '2025-2026',
'semester' => 'Fall',
],
]);
DB::table('classSection')->insert([
[
'class_section_id' => 11,
'class_section_name' => 'Grade 6A',
],
]);
DB::table('users')->insert([
[
'id' => 200,
'firstname' => 'Solo',
'lastname' => 'Teacher',
'email' => 'solo@test.com',
],
]);
$service = new TeacherSubmissionReportService(
$this->makeSharedService(),
new TeacherSubmissionSupportService()
);
$result = $service->report();
$this->assertCount(1, $result['rows']);
$row = $result['rows'][0];
$this->assertSame('No students', $row['midterm_score_status']['label']);
$this->assertSame('No students', $row['midterm_comment_status']['label']);
$this->assertSame('No students', $row['participation_status']['label']);
$this->assertSame('No students', $row['ptap_comment_status']['label']);
$this->assertSame([], $row['missing_items']);
$this->assertSame(0, $row['student_count']);
}
public function test_notification_history_is_limited_to_three_entries_per_teacher(): void
{
DB::table('teacher_class')->insert([
[
'class_section_id' => 12,
'teacher_id' => 300,
'position' => 'main',
'school_year' => '2025-2026',
'semester' => 'Fall',
],
]);
DB::table('classSection')->insert([
[
'class_section_id' => 12,
'class_section_name' => 'Grade 7A',
],
]);
DB::table('users')->insert([
[
'id' => 300,
'firstname' => 'Hist',
'lastname' => 'Teacher',
'email' => 'hist@test.com',
],
[
'id' => 901,
'firstname' => 'Admin',
'lastname' => 'Two',
'email' => 'admin2@test.com',
],
]);
for ($i = 0; $i < 4; $i++) {
TeacherSubmissionNotificationHistory::create([
'teacher_id' => 300,
'class_section_id' => 12,
'admin_id' => 901,
'notification_category' => 'teacher_submissions',
'message' => 'Reminder ' . $i,
'status' => 'sent',
'school_year' => '2025-2026',
'semester' => 'Fall',
'sent_at' => now()->subMinutes($i),
]);
}
$service = new TeacherSubmissionReportService(
$this->makeSharedService(),
new TeacherSubmissionSupportService()
);
$result = $service->report();
$this->assertCount(3, $result['notificationHistory'][12][300]);
}
}
@@ -0,0 +1,127 @@
<?php
namespace Tests\Unit\Services\Administrator;
use App\Services\Administrator\TeacherSubmissionSupportService;
use PHPUnit\Framework\TestCase;
class TeacherSubmissionSupportServiceTest extends TestCase
{
protected TeacherSubmissionSupportService $service;
protected function setUp(): void
{
parent::setUp();
$this->service = new TeacherSubmissionSupportService();
}
public function test_submission_status_returns_no_students_when_expected_is_zero(): void
{
$result = $this->service->submissionStatus(0, 0);
$this->assertSame('No students', $result['label']);
$this->assertSame('bg-secondary', $result['badge']);
$this->assertTrue($result['completed']);
}
public function test_submission_status_returns_submitted_when_filled_matches_expected(): void
{
$result = $this->service->submissionStatus(10, 10);
$this->assertSame('Submitted', $result['label']);
$this->assertSame('bg-success', $result['badge']);
$this->assertSame('10/10', $result['detail']);
$this->assertTrue($result['completed']);
}
public function test_submission_status_returns_missing_when_not_complete(): void
{
$result = $this->service->submissionStatus(7, 10);
$this->assertSame('Missing', $result['label']);
$this->assertSame('bg-danger', $result['badge']);
$this->assertSame('7/10', $result['detail']);
$this->assertFalse($result['completed']);
}
public function test_attendance_status_returns_submitted(): void
{
$result = $this->service->attendanceStatus(true);
$this->assertSame('Submitted', $result['label']);
$this->assertSame('bg-success', $result['badge']);
$this->assertTrue($result['completed']);
}
public function test_attendance_status_returns_missing(): void
{
$result = $this->service->attendanceStatus(false);
$this->assertSame('Missing', $result['label']);
$this->assertSame('bg-danger', $result['badge']);
$this->assertFalse($result['completed']);
}
public function test_build_missing_items_returns_only_incomplete_items(): void
{
$result = $this->service->buildMissingItems([
'midterm_score_status' => ['completed' => false],
'midterm_comment_status' => ['completed' => true],
'participation_status' => ['completed' => false],
'ptap_comment_status' => ['completed' => true],
'attendance_status' => ['completed' => false],
]);
$this->assertSame([
'midterm scores',
'participation',
'attendance',
], $result);
}
public function test_teacher_role_priority_orders_main_before_ta_before_other(): void
{
$this->assertSame(1, $this->service->teacherRolePriority('main'));
$this->assertSame(2, $this->service->teacherRolePriority('ta'));
$this->assertSame(3, $this->service->teacherRolePriority('teacher'));
}
public function test_truncate_notification_message_strips_tags_and_truncates(): void
{
$html = '<p>Hello <strong>world</strong></p>';
$result = $this->service->truncateNotificationMessage($html, 5);
$this->assertSame('Hello…', $result);
}
public function test_parse_missing_items_payload_returns_unique_trimmed_values(): void
{
$payload = base64_encode(json_encode([
' midterm scores ',
'attendance',
'attendance',
'',
]));
$result = $this->service->parseMissingItemsPayload($payload);
$this->assertSame([
'midterm scores',
'attendance',
], $result);
}
public function test_format_missing_items_text_formats_lists_correctly(): void
{
$this->assertSame('', $this->service->formatMissingItemsText([]));
$this->assertSame('attendance', $this->service->formatMissingItemsText(['attendance']));
$this->assertSame(
'attendance and midterm scores',
$this->service->formatMissingItemsText(['attendance', 'midterm scores'])
);
$this->assertSame(
'attendance, midterm scores and participation',
$this->service->formatMissingItemsText(['attendance', 'midterm scores', 'participation'])
);
}
}
+367 -60
View File
@@ -2,82 +2,389 @@
namespace Tests\Unit\Services;
use App\Models\ClassSection;
use App\Models\Configuration;
use App\Models\Student;
use App\Models\StudentClass;
use App\Models\TeacherClass;
use App\Models\User;
use App\Services\AssignmentService;
use Mockery;
use PHPUnit\Framework\TestCase;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class AssignmentServiceTest extends TestCase
{
protected function tearDown(): void
use RefreshDatabase;
protected AssignmentService $service;
protected User $teacherMain;
protected User $teacherAssistant;
protected User $updatedByUser;
protected ClassSection $sectionA;
protected ClassSection $sectionB;
protected Student $studentOne;
protected Student $studentTwo;
protected function setUp(): void
{
Mockery::close();
parent::tearDown();
parent::setUp();
$this->service = app(AssignmentService::class);
Configuration::query()->create([
'config_key' => 'semester',
'config_value' => 'Fall',
]);
Configuration::query()->create([
'config_key' => 'school_year',
'config_value' => '2025-2026',
]);
$this->updatedByUser = User::factory()->create();
$this->teacherMain = User::factory()->create([
'firstname' => 'John',
'lastname' => 'Doe',
]);
$this->teacherAssistant = User::factory()->create([
'firstname' => 'Jane',
'lastname' => 'Smith',
]);
$this->sectionA = ClassSection::factory()->create([
'section_name' => 'Alpha Section',
]);
$this->sectionB = ClassSection::factory()->create([
'section_name' => 'Beta Section',
]);
$this->studentOne = Student::factory()->create([
'firstname' => 'Ali',
'lastname' => 'One',
'age' => 10,
'gender' => 'Male',
'registration_grade' => 'Grade 4',
'photo_consent' => 1,
'tuition_paid' => 0,
'school_id' => 'S-100',
'is_active' => 1,
]);
$this->studentTwo = Student::factory()->create([
'firstname' => 'Maya',
'lastname' => 'Two',
'age' => 11,
'gender' => 'Female',
'registration_grade' => 'Grade 5',
'photo_consent' => 0,
'tuition_paid' => 1,
'school_id' => 'S-101',
'is_active' => 1,
]);
}
/** @test */
public function save_student_assignment_uses_config_defaults_and_calls_update_or_create(): void
public function test_get_current_semester_returns_config_value(): void
{
$userModel = Mockery::mock();
$studentModel = Mockery::mock();
$classSectionModel = Mockery::mock();
$teacherClassModel = Mockery::mock();
$this->assertSame('Fall', $this->service->getCurrentSemester());
}
$configModel = Mockery::mock();
$studentClassModel = Mockery::mock();
$studentClassQuery = Mockery::mock();
public function test_get_current_school_year_returns_config_value(): void
{
$this->assertSame('2025-2026', $this->service->getCurrentSchoolYear());
}
// Config defaults
$configModel->shouldReceive('getConfig')->with('semester')->andReturn('Fall');
$configModel->shouldReceive('getConfig')->with('school_year')->andReturn('2025-2026');
public function test_get_assignments_overview_returns_grouped_sections(): void
{
TeacherClass::query()->create([
'teacher_id' => $this->teacherMain->id,
'class_section_id' => $this->sectionA->id,
'position' => 'main',
'semester' => 'Fall',
'school_year' => '2025-2026',
'description' => 'Alpha description',
]);
// Eloquent chain: newQuery()->updateOrCreate(...)
$studentClassModel->shouldReceive('newQuery')->once()->andReturn($studentClassQuery);
TeacherClass::query()->create([
'teacher_id' => $this->teacherAssistant->id,
'class_section_id' => $this->sectionA->id,
'position' => 'ta',
'semester' => 'Fall',
'school_year' => '2025-2026',
'description' => 'Alpha description',
]);
$expectedRecord = (object) [
'id' => 99,
'student_id' => 10,
'class_section_id' => 7,
'semester' => 'Fall',
'school_year' => '2025-2026',
'description' => 'Auto-assigned',
'updated_by' => 55,
];
StudentClass::query()->create([
'student_id' => $this->studentOne->id,
'class_section_id' => $this->sectionA->id,
'semester' => 'Fall',
'school_year' => '2025-2026',
'description' => 'Alpha description',
'updated_by' => $this->updatedByUser->id,
]);
$studentClassQuery->shouldReceive('updateOrCreate')
->once()
->with(
[
'student_id' => 10,
'school_year' => '2025-2026',
'semester' => 'Fall',
],
Mockery::on(function ($payload) {
return $payload['student_id'] === 10
&& $payload['class_section_id'] === 7
&& $payload['semester'] === 'Fall'
&& $payload['school_year'] === '2025-2026'
&& $payload['description'] === 'Auto-assigned'
&& $payload['updated_by'] === 55;
})
)
->andReturn($expectedRecord);
$result = $this->service->getAssignmentsOverview('2025-2026', 'Fall');
$service = new AssignmentService(
$userModel,
$configModel,
$studentModel,
$classSectionModel,
$teacherClassModel,
$studentClassModel
$this->assertSame('2025-2026', $result['schoolYear']);
$this->assertSame('2025-2026', $result['selectedYear']);
$this->assertSame('Fall', $result['selectedSemester']);
$this->assertSame('Fall', $result['semester']);
$this->assertSame('2025-2026', $result['current_school_year']);
$this->assertCount(1, $result['classSections']);
$section = $result['classSections'][0];
$this->assertSame($this->sectionA->id, $section['class_section_id']);
$this->assertSame('Alpha Section', $section['class_section_name']);
$this->assertSame(['John Doe'], $section['main_teachers']);
$this->assertSame(['Jane Smith'], $section['teacher_assistants']);
$this->assertSame('Fall', $section['semester']);
$this->assertSame('2025-2026', $section['school_year']);
$this->assertSame('Alpha description', $section['description']);
$this->assertCount(1, $section['students']);
$this->assertSame('Ali', $section['students'][0]['firstname']);
}
public function test_get_assignments_overview_uses_fallback_config_values_when_metadata_missing(): void
{
TeacherClass::query()->create([
'teacher_id' => $this->teacherMain->id,
'class_section_id' => $this->sectionA->id,
'position' => 'main',
'semester' => null,
'school_year' => null,
'description' => null,
]);
$result = $this->service->getAssignmentsOverview();
$section = $result['classSections'][0];
$this->assertSame('Fall', $section['semester']);
$this->assertSame('2025-2026', $section['school_year']);
$this->assertSame('', $section['description']);
}
public function test_get_assignments_overview_excludes_inactive_students(): void
{
$inactiveStudent = Student::factory()->create([
'firstname' => 'Inactive',
'lastname' => 'Student',
'is_active' => 0,
]);
TeacherClass::query()->create([
'teacher_id' => $this->teacherMain->id,
'class_section_id' => $this->sectionA->id,
'position' => 'main',
'semester' => 'Fall',
'school_year' => '2025-2026',
'description' => 'Alpha description',
]);
StudentClass::query()->create([
'student_id' => $inactiveStudent->id,
'class_section_id' => $this->sectionA->id,
'semester' => 'Fall',
'school_year' => '2025-2026',
'description' => 'Should not appear',
'updated_by' => $this->updatedByUser->id,
]);
$result = $this->service->getAssignmentsOverview();
$section = $result['classSections'][0];
$this->assertSame([], $section['students']);
}
public function test_get_assignments_overview_sorts_sections_by_name(): void
{
TeacherClass::query()->create([
'teacher_id' => $this->teacherMain->id,
'class_section_id' => $this->sectionB->id,
'position' => 'main',
'semester' => 'Fall',
'school_year' => '2025-2026',
'description' => 'Beta description',
]);
TeacherClass::query()->create([
'teacher_id' => $this->teacherAssistant->id,
'class_section_id' => $this->sectionA->id,
'position' => 'main',
'semester' => 'Fall',
'school_year' => '2025-2026',
'description' => 'Alpha description',
]);
$result = $this->service->getAssignmentsOverview();
$this->assertCount(2, $result['classSections']);
$this->assertSame('Alpha Section', $result['classSections'][0]['class_section_name']);
$this->assertSame('Beta Section', $result['classSections'][1]['class_section_name']);
}
public function test_get_assignments_overview_collects_school_years_descending(): void
{
TeacherClass::query()->create([
'teacher_id' => $this->teacherMain->id,
'class_section_id' => $this->sectionA->id,
'position' => 'main',
'semester' => 'Fall',
'school_year' => '2024-2025',
'description' => 'Old year',
]);
TeacherClass::query()->create([
'teacher_id' => $this->teacherAssistant->id,
'class_section_id' => $this->sectionB->id,
'position' => 'main',
'semester' => 'Fall',
'school_year' => '2025-2026',
'description' => 'New year',
]);
$result = $this->service->getAssignmentsOverview();
$this->assertSame(['2025-2026', '2024-2025'], array_values($result['schoolYears']));
}
public function test_store_assignment_creates_new_row(): void
{
$assignment = $this->service->storeAssignment([
'student_id' => $this->studentOne->id,
'class_section_id' => $this->sectionA->id,
'semester' => 'Fall',
'school_year' => '2025-2026',
'description' => 'Created by service',
], $this->updatedByUser->id);
$this->assertInstanceOf(StudentClass::class, $assignment);
$this->assertDatabaseHas('student_class', [
'student_id' => $this->studentOne->id,
'class_section_id' => $this->sectionA->id,
'semester' => 'Fall',
'school_year' => '2025-2026',
'description' => 'Created by service',
'updated_by' => $this->updatedByUser->id,
]);
}
public function test_store_assignment_updates_existing_row(): void
{
StudentClass::query()->create([
'student_id' => $this->studentOne->id,
'class_section_id' => $this->sectionA->id,
'semester' => 'Fall',
'school_year' => '2025-2026',
'description' => 'Old',
'updated_by' => $this->updatedByUser->id,
]);
$assignment = $this->service->storeAssignment([
'student_id' => $this->studentOne->id,
'class_section_id' => $this->sectionA->id,
'semester' => 'Fall',
'school_year' => '2025-2026',
'description' => 'New',
], $this->updatedByUser->id);
$this->assertSame('New', $assignment->description);
$this->assertSame(
1,
StudentClass::query()
->where('student_id', $this->studentOne->id)
->where('class_section_id', $this->sectionA->id)
->where('semester', 'Fall')
->where('school_year', '2025-2026')
->count()
);
$result = $service->saveStudentAssignment([
'student_id' => 10,
'class_section_id' => 7,
'description' => 'Auto-assigned',
// semester/school_year intentionally omitted to test fallback
], 55);
$this->assertDatabaseHas('student_class', [
'student_id' => $this->studentOne->id,
'class_section_id' => $this->sectionA->id,
'semester' => 'Fall',
'school_year' => '2025-2026',
'description' => 'New',
]);
}
$this->assertSame($expectedRecord, $result);
public function test_get_class_assignment_data_returns_all_sections(): void
{
TeacherClass::query()->create([
'teacher_id' => $this->teacherMain->id,
'class_section_id' => $this->sectionA->id,
'position' => 'main',
'semester' => 'Fall',
'school_year' => '2025-2026',
'description' => 'Alpha description',
]);
TeacherClass::query()->create([
'teacher_id' => $this->teacherAssistant->id,
'class_section_id' => $this->sectionB->id,
'position' => 'ta',
'semester' => 'Spring',
'school_year' => '2025-2026',
'description' => 'Beta description',
]);
StudentClass::query()->create([
'student_id' => $this->studentOne->id,
'class_section_id' => $this->sectionA->id,
'semester' => 'Fall',
'school_year' => '2025-2026',
'description' => 'Alpha description',
'updated_by' => $this->updatedByUser->id,
]);
StudentClass::query()->create([
'student_id' => $this->studentTwo->id,
'class_section_id' => $this->sectionB->id,
'semester' => 'Spring',
'school_year' => '2025-2026',
'description' => 'Beta description',
'updated_by' => $this->updatedByUser->id,
]);
$result = $this->service->getClassAssignmentData();
$this->assertSame('Fall', $result['semester']);
$this->assertSame('2025-2026', $result['school_year']);
$this->assertCount(2, $result['classSections']);
$this->assertSame('Alpha Section', $result['classSections'][0]['class_section_name']);
$this->assertSame('Beta Section', $result['classSections'][1]['class_section_name']);
}
public function test_get_class_assignment_data_deduplicates_teacher_names(): void
{
TeacherClass::query()->create([
'teacher_id' => $this->teacherMain->id,
'class_section_id' => $this->sectionA->id,
'position' => 'main',
'semester' => 'Fall',
'school_year' => '2025-2026',
'description' => 'Alpha description',
]);
TeacherClass::query()->create([
'teacher_id' => $this->teacherMain->id,
'class_section_id' => $this->sectionA->id,
'position' => 'main',
'semester' => 'Fall',
'school_year' => '2025-2026',
'description' => 'Alpha description',
]);
$result = $this->service->getClassAssignmentData();
$this->assertSame(
['John Doe'],
$result['classSections'][0]['main_teachers']
);
}
}
@@ -0,0 +1,23 @@
<?php
namespace Tests\Feature\Api\Attendance;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Laravel\Sanctum\Sanctum;
use Tests\TestCase;
class AdminAttendanceApiControllerTest extends TestCase
{
use RefreshDatabase;
public function test_daily_data_returns_success(): void
{
$user = User::factory()->create();
Sanctum::actingAs($user);
$response = $this->getJson('/api/attendance/admin/daily');
$response->assertStatus(200);
}
}
@@ -0,0 +1,57 @@
<?php
namespace Tests\Unit\Services\Attendance;
use App\Services\Attendance\AttendancePolicyService;
use PHPUnit\Framework\TestCase;
class AttendancePolicyServiceTest extends TestCase
{
public function test_teacher_can_edit_draft_day(): void
{
$service = new AttendancePolicyService();
$day = ['status' => 'draft'];
$user = [
'roles' => ['teacher'],
'permissions' => [],
];
$this->assertTrue($service->canEditDay($day, $user));
}
public function test_teacher_cannot_edit_finalized_day(): void
{
$service = new AttendancePolicyService();
$day = ['status' => 'finalized'];
$user = [
'roles' => ['teacher'],
'permissions' => [],
];
$this->assertFalse($service->canEditDay($day, $user));
}
public function test_admin_can_edit_finalized_day(): void
{
$service = new AttendancePolicyService();
$day = ['status' => 'finalized'];
$user = [
'roles' => ['admin'],
'permissions' => [],
];
$this->assertTrue($service->canEditDay($day, $user));
}
public function test_is_admin_like_excludes_teacher_roles(): void
{
$service = new AttendancePolicyService();
$this->assertFalse($service->isAdminLike(['teacher']));
$this->assertFalse($service->isAdminLike(['teacher_assistant']));
$this->assertTrue($service->isAdminLike(['principal']));
}
}
@@ -0,0 +1,267 @@
<?php
namespace Tests\Unit\Services\Attendance;
use App\Models\AttendanceRecord;
use App\Services\Attendance\AttendanceRecordSyncService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class AttendanceRecordSyncServiceTest extends TestCase
{
use RefreshDatabase;
protected AttendanceRecordSyncService $service;
protected function setUp(): void
{
parent::setUp();
$this->service = app(AttendanceRecordSyncService::class);
}
public function test_update_attendance_record_increments_and_decrements_counters(): void
{
$record = AttendanceRecord::query()->create([
'class_section_id' => 10,
'student_id' => 200,
'school_id' => 'SCH-200',
'semester' => 'Fall',
'school_year' => '2025-2026',
'total_presence' => 5,
'total_absence' => 2,
'total_late' => 1,
'total_attendance' => 8,
'modified_by' => 1,
'created_at' => now(),
'updated_at' => now(),
]);
$this->service->updateAttendanceRecord(
studentId: 200,
schoolId: 'SCH-200',
newStatus: 'absent',
semester: 'Fall',
schoolYear: '2025-2026',
oldStatus: 'present',
modifiedBy: 99
);
$record->refresh();
$this->assertSame(4, (int)$record->total_presence);
$this->assertSame(3, (int)$record->total_absence);
$this->assertSame(1, (int)$record->total_late);
$this->assertSame(99, (int)$record->modified_by);
}
public function test_update_attendance_record_does_nothing_when_record_missing(): void
{
$this->service->updateAttendanceRecord(
studentId: 999,
schoolId: 'SCH-999',
newStatus: 'late',
semester: 'Fall',
schoolYear: '2025-2026',
oldStatus: 'present',
modifiedBy: 10
);
$this->assertDatabaseMissing('attendance_record', [
'student_id' => 999,
'semester' => 'Fall',
'school_year' => '2025-2026',
]);
}
public function test_update_attendance_record_never_goes_below_zero(): void
{
$record = AttendanceRecord::query()->create([
'class_section_id' => 11,
'student_id' => 201,
'school_id' => 'SCH-201',
'semester' => 'Fall',
'school_year' => '2025-2026',
'total_presence' => 0,
'total_absence' => 0,
'total_late' => 0,
'total_attendance' => 0,
'modified_by' => 1,
'created_at' => now(),
'updated_at' => now(),
]);
$this->service->updateAttendanceRecord(
studentId: 201,
schoolId: 'SCH-201',
newStatus: 'present',
semester: 'Fall',
schoolYear: '2025-2026',
oldStatus: 'absent',
modifiedBy: 5
);
$record->refresh();
$this->assertSame(1, (int)$record->total_presence);
$this->assertSame(0, (int)$record->total_absence);
$this->assertSame(0, (int)$record->total_late);
}
public function test_add_new_attendance_record_creates_record_when_missing(): void
{
$this->service->addNewAttendanceRecord(
classSectionId: 20,
studentId: 300,
schoolId: 'SCH-300',
status: 'late',
semester: 'Spring',
schoolYear: '2025-2026',
modifiedBy: 88
);
$this->assertDatabaseHas('attendance_record', [
'class_section_id' => 20,
'student_id' => 300,
'school_id' => 'SCH-300',
'semester' => 'Spring',
'school_year' => '2025-2026',
'total_presence' => 0,
'total_absence' => 0,
'total_late' => 1,
'total_attendance' => 1,
'modified_by' => 88,
]);
}
public function test_add_new_attendance_record_updates_existing_record(): void
{
$record = AttendanceRecord::query()->create([
'class_section_id' => 21,
'student_id' => 301,
'school_id' => 'SCH-301',
'semester' => 'Spring',
'school_year' => '2025-2026',
'total_presence' => 1,
'total_absence' => 2,
'total_late' => 3,
'total_attendance' => 6,
'modified_by' => 1,
'created_at' => now(),
'updated_at' => now(),
]);
$this->service->addNewAttendanceRecord(
classSectionId: 21,
studentId: 301,
schoolId: 'SCH-301',
status: 'present',
semester: 'Spring',
schoolYear: '2025-2026',
modifiedBy: 77
);
$record->refresh();
$this->assertSame(2, (int)$record->total_presence);
$this->assertSame(2, (int)$record->total_absence);
$this->assertSame(3, (int)$record->total_late);
$this->assertSame(7, (int)$record->total_attendance);
$this->assertSame(77, (int)$record->modified_by);
}
public function test_upsert_attendance_record_totals_updates_existing_record(): void
{
$record = AttendanceRecord::query()->create([
'class_section_id' => 30,
'student_id' => 400,
'school_id' => 'SCH-400',
'semester' => 'Fall',
'school_year' => '2025-2026',
'total_presence' => 0,
'total_absence' => 0,
'total_late' => 0,
'total_attendance' => 0,
'modified_by' => 1,
'created_at' => now(),
'updated_at' => now(),
]);
$result = $this->service->upsertAttendanceRecordTotals(
studentId: 400,
classSectionId: '30',
schoolId: 'SCH-400',
semester: 'Fall',
schoolYear: '2025-2026',
totalPresence: 7,
totalLate: 2,
totalAbsence: 1,
totalAttendance: 10,
modifiedBy: 44
);
$this->assertTrue($result);
$record->refresh();
$this->assertSame(7, (int)$record->total_presence);
$this->assertSame(2, (int)$record->total_late);
$this->assertSame(1, (int)$record->total_absence);
$this->assertSame(10, (int)$record->total_attendance);
$this->assertSame(44, (int)$record->modified_by);
}
public function test_upsert_attendance_record_totals_creates_new_record(): void
{
$result = $this->service->upsertAttendanceRecordTotals(
studentId: 401,
classSectionId: '31',
schoolId: 'SCH-401',
semester: 'Fall',
schoolYear: '2025-2026',
totalPresence: 3,
totalLate: 1,
totalAbsence: 2,
totalAttendance: 6,
modifiedBy: 55
);
$this->assertTrue($result);
$this->assertDatabaseHas('attendance_record', [
'student_id' => 401,
'class_section_id' => 31,
'school_id' => 'SCH-401',
'semester' => 'Fall',
'school_year' => '2025-2026',
'total_presence' => 3,
'total_late' => 1,
'total_absence' => 2,
'total_attendance' => 6,
'modified_by' => 55,
]);
}
public function test_upsert_attendance_record_totals_returns_false_when_semester_or_school_year_missing(): void
{
$result = $this->service->upsertAttendanceRecordTotals(
studentId: 402,
classSectionId: '32',
schoolId: 'SCH-402',
semester: '',
schoolYear: '',
totalPresence: 1,
totalLate: 1,
totalAbsence: 1,
totalAttendance: 3,
modifiedBy: 1
);
$this->assertFalse($result);
$this->assertDatabaseMissing('attendance_record', [
'student_id' => 402,
'school_id' => 'SCH-402',
]);
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,40 @@
<?php
namespace Tests\Unit\Services\Attendance;
use App\Services\Attendance\SemesterRangeService;
use PHPUnit\Framework\TestCase;
class SemesterRangeServiceTest extends TestCase
{
public function test_normalize_semester(): void
{
$service = new SemesterRangeService();
$this->assertSame('Fall', $service->normalizeSemester('fall'));
$this->assertSame('Spring', $service->normalizeSemester('SPRING'));
$this->assertSame('', $service->normalizeSemester('summer'));
}
public function test_get_school_year_range(): void
{
$service = new SemesterRangeService();
[$start, $end] = $service->getSchoolYearRange('2025-2026');
$this->assertSame('2025-09-01', $start);
$this->assertSame('2026-05-31', $end);
}
public function test_build_sunday_list(): void
{
$service = new SemesterRangeService();
$sundays = $service->buildSundayList('2025-09-01', '2025-09-30');
$this->assertContains('2025-09-07', $sundays);
$this->assertContains('2025-09-14', $sundays);
$this->assertContains('2025-09-21', $sundays);
$this->assertContains('2025-09-28', $sundays);
}
}
@@ -0,0 +1,33 @@
<?php
namespace Tests\Feature\Api\Attendance;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Laravel\Sanctum\Sanctum;
use Tests\TestCase;
class StaffAttendanceApiControllerTest extends TestCase
{
use RefreshDatabase;
public function test_month_data_returns_success(): void
{
$user = User::factory()->create();
Sanctum::actingAs($user);
$response = $this->getJson('/api/attendance/staff/month');
$response->assertStatus(200);
}
public function test_admins_data_returns_success(): void
{
$user = User::factory()->create();
Sanctum::actingAs($user);
$response = $this->getJson('/api/attendance/staff/admins');
$response->assertStatus(200);
}
}
@@ -0,0 +1,322 @@
<?php
namespace Tests\Unit\Services\Attendance;
use App\Models\AttendanceData;
use App\Models\AttendanceRecord;
use App\Models\Student;
use App\Services\Attendance\AttendanceRecordSyncService;
use App\Services\Attendance\StudentAttendanceWriterService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use RuntimeException;
use Tests\TestCase;
class StudentAttendanceWriterServiceTest extends TestCase
{
use RefreshDatabase;
protected StudentAttendanceWriterService $service;
protected function setUp(): void
{
parent::setUp();
$this->service = app(StudentAttendanceWriterService::class);
}
public function test_resolve_student_school_id_returns_passed_value_when_present(): void
{
$result = $this->service->resolveStudentSchoolId(100, 'SCH-100');
$this->assertSame('SCH-100', $result);
}
public function test_resolve_student_school_id_fetches_from_database_when_missing(): void
{
Student::query()->create([
'id' => 101,
'firstname' => 'John',
'lastname' => 'Doe',
'school_id' => 'SCH-101',
'is_active' => 1,
'created_at' => now(),
'updated_at' => now(),
]);
$result = $this->service->resolveStudentSchoolId(101, null);
$this->assertSame('SCH-101', $result);
}
public function test_resolve_student_school_id_throws_when_student_missing(): void
{
$this->expectException(RuntimeException::class);
$this->expectExceptionMessage('Student school ID not found.');
$this->service->resolveStudentSchoolId(9999, null);
}
public function test_insert_attendance_data_creates_new_row(): void
{
$this->service->insertAttendanceData(
classSectionId: 10,
studentId: 200,
studentSchoolId: 'SCH-200',
status: 'Absent',
reason: 'Sick',
reported: 'yes',
semester: 'Fall',
schoolYear: '2025-2026',
date: '2025-10-05',
classId: 5,
userId: 22
);
$this->assertDatabaseHas('attendance_data', [
'class_id' => 5,
'class_section_id' => 10,
'student_id' => 200,
'school_id' => 'SCH-200',
'status' => 'absent',
'reason' => 'Sick',
'is_reported' => 'yes',
'is_notified' => 'no',
'semester' => 'Fall',
'school_year' => '2025-2026',
'date' => '2025-10-05',
'modified_by' => 22,
]);
}
public function test_update_attendance_data_updates_existing_row(): void
{
$row = AttendanceData::query()->create([
'class_id' => 6,
'class_section_id' => 12,
'student_id' => 201,
'school_id' => 'SCH-201',
'status' => 'present',
'reason' => null,
'is_reported' => 'no',
'is_notified' => 'no',
'semester' => 'Fall',
'school_year' => '2025-2026',
'date' => '2025-10-12',
'modified_by' => 1,
'created_at' => now(),
'updated_at' => now(),
]);
$this->service->updateAttendanceData(
attendanceId: $row->id,
newStatus: 'late',
newReason: 'Traffic',
reported: 'yes',
userId: 99
);
$row->refresh();
$this->assertSame('late', $row->status);
$this->assertSame('Traffic', $row->reason);
$this->assertSame('yes', $row->is_reported);
$this->assertSame(99, (int)$row->modified_by);
}
public function test_save_or_update_student_attendance_creates_new_attendance_and_record(): void
{
Student::query()->create([
'id' => 300,
'firstname' => 'Sara',
'lastname' => 'Smith',
'school_id' => 'SCH-300',
'is_active' => 1,
'created_at' => now(),
'updated_at' => now(),
]);
$result = $this->service->saveOrUpdateStudentAttendance([
'class_section_id' => 15,
'student_id' => 300,
'school_id' => null,
'status' => 'present',
'reason' => 'On time',
'is_reported' => 'no',
'semester' => 'Fall',
'school_year' => '2025-2026',
'date' => '2025-10-19',
'class_id' => 9,
'user_id' => 88,
]);
$this->assertSame('created', $result['mode']);
$this->assertSame('present', $result['new_status']);
$this->assertSame('SCH-300', $result['school_id']);
$this->assertDatabaseHas('attendance_data', [
'student_id' => 300,
'school_id' => 'SCH-300',
'status' => 'present',
'semester' => 'Fall',
'school_year' => '2025-2026',
'date' => '2025-10-19',
]);
$this->assertDatabaseHas('attendance_record', [
'student_id' => 300,
'school_id' => 'SCH-300',
'semester' => 'Fall',
'school_year' => '2025-2026',
'total_presence' => 1,
'total_absence' => 0,
'total_late' => 0,
'total_attendance' => 1,
'modified_by' => 88,
]);
}
public function test_save_or_update_student_attendance_updates_existing_attendance_and_record(): void
{
Student::query()->create([
'id' => 301,
'firstname' => 'Ali',
'lastname' => 'Khan',
'school_id' => 'SCH-301',
'is_active' => 1,
'created_at' => now(),
'updated_at' => now(),
]);
AttendanceRecord::query()->create([
'class_section_id' => 16,
'student_id' => 301,
'school_id' => 'SCH-301',
'semester' => 'Fall',
'school_year' => '2025-2026',
'total_presence' => 2,
'total_absence' => 0,
'total_late' => 0,
'total_attendance' => 2,
'modified_by' => 1,
'created_at' => now(),
'updated_at' => now(),
]);
$attendance = AttendanceData::query()->create([
'class_id' => 10,
'class_section_id' => 16,
'student_id' => 301,
'school_id' => 'SCH-301',
'status' => 'present',
'reason' => null,
'is_reported' => 'no',
'is_notified' => 'no',
'semester' => 'Fall',
'school_year' => '2025-2026',
'date' => '2025-10-26',
'modified_by' => 1,
'created_at' => now(),
'updated_at' => now(),
]);
$result = $this->service->saveOrUpdateStudentAttendance([
'class_section_id' => 16,
'student_id' => 301,
'school_id' => 'SCH-301',
'status' => 'absent',
'reason' => 'Family trip',
'is_reported' => 'yes',
'semester' => 'Fall',
'school_year' => '2025-2026',
'date' => '2025-10-26',
'class_id' => 10,
'user_id' => 77,
]);
$this->assertSame('updated', $result['mode']);
$this->assertSame('present', $result['old_status']);
$this->assertSame('absent', $result['new_status']);
$attendance->refresh();
$this->assertSame('absent', $attendance->status);
$this->assertSame('Family trip', $attendance->reason);
$this->assertSame('yes', $attendance->is_reported);
$this->assertSame(77, (int)$attendance->modified_by);
$this->assertDatabaseHas('attendance_record', [
'student_id' => 301,
'semester' => 'Fall',
'school_year' => '2025-2026',
'total_presence' => 1,
'total_absence' => 1,
'total_late' => 0,
]);
}
public function test_save_or_update_student_attendance_updates_existing_without_counter_change_when_status_same(): void
{
Student::query()->create([
'id' => 302,
'firstname' => 'Mona',
'lastname' => 'Lee',
'school_id' => 'SCH-302',
'is_active' => 1,
'created_at' => now(),
'updated_at' => now(),
]);
$record = AttendanceRecord::query()->create([
'class_section_id' => 17,
'student_id' => 302,
'school_id' => 'SCH-302',
'semester' => 'Fall',
'school_year' => '2025-2026',
'total_presence' => 4,
'total_absence' => 1,
'total_late' => 0,
'total_attendance' => 5,
'modified_by' => 1,
'created_at' => now(),
'updated_at' => now(),
]);
AttendanceData::query()->create([
'class_id' => 11,
'class_section_id' => 17,
'student_id' => 302,
'school_id' => 'SCH-302',
'status' => 'present',
'reason' => null,
'is_reported' => 'no',
'is_notified' => 'no',
'semester' => 'Fall',
'school_year' => '2025-2026',
'date' => '2025-11-02',
'modified_by' => 1,
'created_at' => now(),
'updated_at' => now(),
]);
$this->service->saveOrUpdateStudentAttendance([
'class_section_id' => 17,
'student_id' => 302,
'school_id' => 'SCH-302',
'status' => 'present',
'reason' => 'Still present',
'is_reported' => 'no',
'semester' => 'Fall',
'school_year' => '2025-2026',
'date' => '2025-11-02',
'class_id' => 11,
'user_id' => 66,
]);
$record->refresh();
$this->assertSame(4, (int)$record->total_presence);
$this->assertSame(1, (int)$record->total_absence);
$this->assertSame(0, (int)$record->total_late);
$this->assertSame(5, (int)$record->total_attendance);
}
}
@@ -0,0 +1,33 @@
<?php
namespace Tests\Feature\Api\Attendance;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Laravel\Sanctum\Sanctum;
use Tests\TestCase;
class TeacherAttendanceApiControllerTest extends TestCase
{
use RefreshDatabase;
public function test_teacher_grid_endpoint_returns_success(): void
{
$user = User::factory()->create();
Sanctum::actingAs($user);
$response = $this->getJson('/api/attendance/teacher/grid');
$response->assertStatus(200);
}
public function test_teacher_form_endpoint_returns_success_or_validation_message(): void
{
$user = User::factory()->create();
Sanctum::actingAs($user);
$response = $this->getJson('/api/attendance/teacher/form');
$this->assertContains($response->status(), [200, 422]);
}
}
@@ -0,0 +1,545 @@
<?php
namespace Tests\Unit\Services\Attendance;
use App\Models\AttendanceData;
use App\Models\AttendanceDay;
use App\Models\Student;
use App\Models\TeacherClass;
use App\Models\User;
use App\Services\Attendance\AttendancePolicyService;
use App\Services\Attendance\StudentAttendanceWriterService;
use App\Services\Attendance\TeacherAttendanceSubmissionService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Mockery;
use RuntimeException;
use Tests\TestCase;
class TeacherAttendanceSubmissionServiceTest extends TestCase
{
use RefreshDatabase;
protected function tearDown(): void
{
Mockery::close();
parent::tearDown();
}
protected function makeService(
?AttendanceDay $attendanceDay = null,
?AttendanceData $attendanceData = null,
mixed $teacherClass = null,
?Student $student = null,
mixed $attendancePolicyService = null,
mixed $studentAttendanceWriterService = null
): TeacherAttendanceSubmissionService {
return new TeacherAttendanceSubmissionService(
$attendanceDay ?? app(AttendanceDay::class),
$attendanceData ?? app(AttendanceData::class),
$teacherClass ?? app(TeacherClass::class),
$student ?? app(Student::class),
$attendancePolicyService ?? app(AttendancePolicyService::class),
$studentAttendanceWriterService ?? app(StudentAttendanceWriterService::class),
);
}
public function test_submit_throws_when_class_section_missing(): void
{
$service = $this->makeService();
$user = User::factory()->create();
$this->expectException(RuntimeException::class);
$this->expectExceptionMessage('Missing or invalid class section.');
$service->submit(
user: $user,
data: [
'class_section_id' => '',
'attendance' => [],
'teachers' => [],
],
detectExternalSubmission: fn (?array $row) => ['locked' => false, 'source' => null],
currentSemester: fn () => 'Fall',
currentSchoolYear: fn () => '2025-2026'
);
}
public function test_submit_throws_when_section_not_found(): void
{
$service = $this->makeService();
$user = User::factory()->create();
$this->expectException(RuntimeException::class);
$this->expectExceptionMessage('Invalid class section.');
$service->submit(
user: $user,
data: [
'class_section_id' => '999999',
'attendance' => [['student_id' => 1, 'status' => 'present']],
'teachers' => [1 => ['status' => 'present']],
],
detectExternalSubmission: fn (?array $row) => ['locked' => false, 'source' => null],
currentSemester: fn () => 'Fall',
currentSchoolYear: fn () => '2025-2026'
);
}
public function test_submit_throws_when_student_attendance_missing(): void
{
DB::table('classSection')->insert([
'id' => 1,
'class_id' => 5,
'class_section_id' => 100,
]);
$teacherClassMock = Mockery::mock(TeacherClass::class);
$teacherClassMock->shouldReceive('assignedForSectionTerm')
->once()
->andReturn([
[
'teacher_id' => 55,
'position' => 'main',
'firstname' => 'Main',
'lastname' => 'Teacher',
],
]);
$policyMock = Mockery::mock(AttendancePolicyService::class);
$policyMock->shouldReceive('canEditDay')->once()->andReturnTrue();
$writerMock = Mockery::mock(StudentAttendanceWriterService::class);
$service = $this->makeService(
teacherClass: $teacherClassMock,
attendancePolicyService: $policyMock,
studentAttendanceWriterService: $writerMock
);
$user = User::factory()->create();
$this->expectException(RuntimeException::class);
$this->expectExceptionMessage('No student attendance data provided.');
$service->submit(
user: $user,
data: [
'class_section_id' => '100',
'attendance' => [],
'teachers' => [55 => ['status' => 'present']],
],
detectExternalSubmission: fn (?array $row) => ['locked' => false, 'source' => null],
currentSemester: fn () => 'Fall',
currentSchoolYear: fn () => '2025-2026'
);
}
public function test_submit_throws_when_teacher_attendance_missing(): void
{
DB::table('classSection')->insert([
'id' => 2,
'class_id' => 6,
'class_section_id' => 101,
]);
$service = $this->makeService();
$user = User::factory()->create();
$this->expectException(RuntimeException::class);
$this->expectExceptionMessage('No teacher attendance data provided.');
$service->submit(
user: $user,
data: [
'class_section_id' => '101',
'attendance' => [
['student_id' => 1, 'status' => 'present'],
],
'teachers' => [],
],
detectExternalSubmission: fn (?array $row) => ['locked' => false, 'source' => null],
currentSemester: fn () => 'Fall',
currentSchoolYear: fn () => '2025-2026'
);
}
public function test_submit_throws_when_policy_denies_edit(): void
{
DB::table('classSection')->insert([
'id' => 3,
'class_id' => 7,
'class_section_id' => 102,
]);
$teacherClassMock = Mockery::mock(TeacherClass::class);
$teacherClassMock->shouldReceive('assignedForSectionTerm')->never();
$policyMock = Mockery::mock(AttendancePolicyService::class);
$policyMock->shouldReceive('canEditDay')->once()->andReturnFalse();
$writerMock = Mockery::mock(StudentAttendanceWriterService::class);
$service = $this->makeService(
teacherClass: $teacherClassMock,
attendancePolicyService: $policyMock,
studentAttendanceWriterService: $writerMock
);
$user = User::factory()->create();
$this->expectException(RuntimeException::class);
$this->expectExceptionMessage('Attendance is locked for your role.');
$service->submit(
user: $user,
data: [
'class_section_id' => '102',
'attendance' => [
['student_id' => 1, 'status' => 'present'],
],
'teachers' => [
55 => ['status' => 'present'],
],
],
detectExternalSubmission: fn (?array $row) => ['locked' => false, 'source' => null],
currentSemester: fn () => 'Fall',
currentSchoolYear: fn () => '2025-2026'
);
}
public function test_submit_throws_when_assigned_teacher_status_missing_or_invalid(): void
{
DB::table('classSection')->insert([
'id' => 4,
'class_id' => 8,
'class_section_id' => 103,
]);
$teacherClassMock = Mockery::mock(TeacherClass::class);
$teacherClassMock->shouldReceive('assignedForSectionTerm')
->once()
->andReturn([
[
'teacher_id' => 77,
'position' => 'main',
'firstname' => 'Main',
'lastname' => 'Teacher',
],
]);
$policyMock = Mockery::mock(AttendancePolicyService::class);
$policyMock->shouldReceive('canEditDay')->once()->andReturnTrue();
$writerMock = Mockery::mock(StudentAttendanceWriterService::class);
$service = $this->makeService(
teacherClass: $teacherClassMock,
attendancePolicyService: $policyMock,
studentAttendanceWriterService: $writerMock
);
$user = User::factory()->create();
$this->expectException(RuntimeException::class);
$this->expectExceptionMessage('Please fill teacher attendance for all assigned teachers.');
$service->submit(
user: $user,
data: [
'class_section_id' => '103',
'attendance' => [
['student_id' => 1, 'status' => 'present'],
],
'teachers' => [
77 => ['status' => ''],
],
],
detectExternalSubmission: fn (?array $row) => ['locked' => false, 'source' => null],
currentSemester: fn () => 'Fall',
currentSchoolYear: fn () => '2025-2026'
);
}
public function test_submit_saves_teacher_staff_attendance_and_student_rows(): void
{
DB::table('classSection')->insert([
'id' => 5,
'class_id' => 9,
'class_section_id' => 104,
]);
$teacherClassMock = Mockery::mock(TeacherClass::class);
$teacherClassMock->shouldReceive('assignedForSectionTerm')
->once()
->andReturn([
[
'teacher_id' => 88,
'position' => 'main',
'firstname' => 'Lead',
'lastname' => 'Teacher',
],
[
'teacher_id' => 89,
'position' => 'ta',
'firstname' => 'Assist',
'lastname' => 'Teacher',
],
]);
$policyMock = Mockery::mock(AttendancePolicyService::class);
$policyMock->shouldReceive('canEditDay')->once()->andReturnTrue();
$policyMock->shouldReceive('isTeacher')->once()->andReturnTrue();
$writerMock = Mockery::mock(StudentAttendanceWriterService::class);
$writerMock->shouldReceive('saveOrUpdateStudentAttendance')
->twice()
->withArgs(function (array $payload) {
return in_array($payload['student_id'], [501, 502], true)
&& $payload['class_section_id'] === 104
&& $payload['class_id'] === 9
&& $payload['semester'] === 'Fall'
&& $payload['school_year'] === '2025-2026';
})
->andReturn(['mode' => 'created']);
$service = $this->makeService(
teacherClass: $teacherClassMock,
attendancePolicyService: $policyMock,
studentAttendanceWriterService: $writerMock
);
$user = User::factory()->create();
$result = $service->submit(
user: $user,
data: [
'class_section_id' => '104',
'date' => '2025-10-05',
'attendance' => [
[
'student_id' => 501,
'school_id' => 'SCH-501',
'status' => 'present',
'reason' => '',
],
[
'student_id' => 502,
'school_id' => 'SCH-502',
'status' => 'late',
'reason' => 'Bus delay',
],
],
'teachers' => [
88 => ['status' => 'present', 'reason' => null],
89 => ['status' => 'late', 'reason' => 'Traffic'],
],
],
detectExternalSubmission: fn (?array $row) => ['locked' => false, 'source' => null],
currentSemester: fn () => 'Fall',
currentSchoolYear: fn () => '2025-2026'
);
$this->assertSame(['ok' => true, 'message' => 'Attendance submitted successfully.'], $result);
$this->assertDatabaseHas('staff_attendance', [
'user_id' => 88,
'date' => '2025-10-05',
'semester' => 'Fall',
'school_year' => '2025-2026',
'class_section_id' => 104,
'position' => 'main',
'status' => 'present',
]);
$this->assertDatabaseHas('staff_attendance', [
'user_id' => 89,
'date' => '2025-10-05',
'semester' => 'Fall',
'school_year' => '2025-2026',
'class_section_id' => 104,
'position' => 'ta',
'status' => 'late',
'reason' => 'Traffic',
]);
$this->assertDatabaseHas('attendance_day', [
'class_section_id' => 104,
'date' => '2025-10-05',
'semester' => 'Fall',
'school_year' => '2025-2026',
'status' => 'submitted',
'submitted_by' => $user->id,
]);
}
public function test_submit_skips_locked_existing_student_rows_for_teacher(): void
{
DB::table('classSection')->insert([
'id' => 6,
'class_id' => 10,
'class_section_id' => 105,
]);
AttendanceData::query()->create([
'class_id' => 10,
'class_section_id' => 105,
'student_id' => 601,
'school_id' => 'SCH-601',
'status' => 'absent',
'reason' => 'Parent reported',
'is_reported' => 'yes',
'is_notified' => 'no',
'semester' => 'Fall',
'school_year' => '2025-2026',
'date' => '2025-10-12',
'modified_by' => 2,
'created_at' => now(),
'updated_at' => now(),
]);
$teacherClassMock = Mockery::mock(TeacherClass::class);
$teacherClassMock->shouldReceive('assignedForSectionTerm')
->once()
->andReturn([
[
'teacher_id' => 90,
'position' => 'main',
'firstname' => 'Lead',
'lastname' => 'Teacher',
],
]);
$policyMock = Mockery::mock(AttendancePolicyService::class);
$policyMock->shouldReceive('canEditDay')->once()->andReturnTrue();
$policyMock->shouldReceive('isTeacher')->once()->andReturnTrue();
$writerMock = Mockery::mock(StudentAttendanceWriterService::class);
$writerMock->shouldNotReceive('saveOrUpdateStudentAttendance');
$service = $this->makeService(
teacherClass: $teacherClassMock,
attendancePolicyService: $policyMock,
studentAttendanceWriterService: $writerMock
);
$user = User::factory()->create();
$result = $service->submit(
user: $user,
data: [
'class_section_id' => '105',
'date' => '2025-10-12',
'attendance' => [
[
'student_id' => 601,
'school_id' => 'SCH-601',
'status' => 'present',
'reason' => '',
],
],
'teachers' => [
90 => ['status' => 'present', 'reason' => null],
],
],
detectExternalSubmission: fn (?array $row) => ['locked' => true, 'source' => 'parent'],
currentSemester: fn () => 'Fall',
currentSchoolYear: fn () => '2025-2026'
);
$this->assertTrue($result['ok']);
$this->assertDatabaseHas('attendance_day', [
'class_section_id' => 105,
'date' => '2025-10-12',
'semester' => 'Fall',
'school_year' => '2025-2026',
'status' => 'submitted',
]);
}
public function test_submit_allows_non_teacher_context_to_write_existing_rows(): void
{
DB::table('classSection')->insert([
'id' => 7,
'class_id' => 11,
'class_section_id' => 106,
]);
AttendanceData::query()->create([
'class_id' => 11,
'class_section_id' => 106,
'student_id' => 701,
'school_id' => 'SCH-701',
'status' => 'absent',
'reason' => 'Parent reported',
'is_reported' => 'yes',
'is_notified' => 'no',
'semester' => 'Fall',
'school_year' => '2025-2026',
'date' => '2025-10-19',
'modified_by' => 2,
'created_at' => now(),
'updated_at' => now(),
]);
$teacherClassMock = Mockery::mock(TeacherClass::class);
$teacherClassMock->shouldReceive('assignedForSectionTerm')
->once()
->andReturn([
[
'teacher_id' => 91,
'position' => 'main',
'firstname' => 'Lead',
'lastname' => 'Teacher',
],
]);
$policyMock = Mockery::mock(AttendancePolicyService::class);
$policyMock->shouldReceive('canEditDay')->once()->andReturnTrue();
$policyMock->shouldReceive('isTeacher')->once()->andReturnFalse();
$writerMock = Mockery::mock(StudentAttendanceWriterService::class);
$writerMock->shouldReceive('saveOrUpdateStudentAttendance')
->once()
->withArgs(function (array $payload) {
return $payload['student_id'] === 701
&& $payload['class_section_id'] === 106
&& $payload['class_id'] === 11;
})
->andReturn(['mode' => 'updated']);
$service = $this->makeService(
teacherClass: $teacherClassMock,
attendancePolicyService: $policyMock,
studentAttendanceWriterService: $writerMock
);
$user = User::factory()->create();
$result = $service->submit(
user: $user,
data: [
'class_section_id' => '106',
'date' => '2025-10-19',
'attendance' => [
[
'student_id' => 701,
'school_id' => 'SCH-701',
'status' => 'present',
'reason' => '',
],
],
'teachers' => [
91 => ['status' => 'present', 'reason' => null],
],
],
detectExternalSubmission: fn (?array $row) => ['locked' => true, 'source' => 'parent'],
currentSemester: fn () => 'Fall',
currentSchoolYear: fn () => '2025-2026'
);
$this->assertTrue($result['ok']);
}
}
@@ -0,0 +1,45 @@
<?php
namespace Tests\Unit\Services\AttendanceTracking;
use App\Models\AttendanceData;
use App\Models\AttendanceTracking;
use App\Models\Student;
use App\Services\AttendanceCaseQueryService;
use App\Services\AttendanceParentLookupService;
use App\Services\ViolationRuleEngineService;
use Mockery;
use Tests\TestCase;
class AttendanceCaseQueryServiceTest extends TestCase
{
protected function tearDown(): void
{
Mockery::close();
parent::tearDown();
}
public function test_get_student_case_returns_404_when_student_not_found(): void
{
$student = Mockery::mock(Student::class);
$student->shouldReceive('query->find')->with(99)->andReturn(null);
$attendanceData = Mockery::mock(AttendanceData::class);
$tracking = Mockery::mock(AttendanceTracking::class);
$rules = Mockery::mock(ViolationRuleEngineService::class);
$parents = Mockery::mock(AttendanceParentLookupService::class);
$service = new AttendanceCaseQueryService(
$student,
$attendanceData,
$tracking,
$rules,
$parents
);
$result = $service->getStudentCase(99);
$this->assertFalse($result['success']);
$this->assertSame(404, $result['status']);
}
}
@@ -0,0 +1,73 @@
<?php
namespace Tests\Unit\Services\AttendanceTracking;
use App\Models\AttendanceData;
use App\Models\Configuration;
use App\Models\Student;
use App\Services\AttendanceCommunicationSupportService;
use App\Services\AttendanceEmailComposerService;
use App\Services\AttendanceParentLookupService;
use Mockery;
use Tests\TestCase;
class AttendanceCommunicationSupportServiceTest extends TestCase
{
protected function tearDown(): void
{
Mockery::close();
parent::tearDown();
}
protected function makeService(
?Student $student = null,
?AttendanceData $attendanceData = null,
?Configuration $config = null,
?AttendanceParentLookupService $parents = null,
?AttendanceEmailComposerService $composer = null
): AttendanceCommunicationSupportService {
$student ??= Mockery::mock(Student::class);
$attendanceData ??= Mockery::mock(AttendanceData::class);
$config ??= Mockery::mock(Configuration::class);
$parents ??= Mockery::mock(AttendanceParentLookupService::class);
$composer ??= Mockery::mock(AttendanceEmailComposerService::class);
$config->shouldReceive('getConfig')->with('semester')->andReturn('Fall');
$config->shouldReceive('getConfig')->with('school_year')->andReturn('2025-2026');
return new AttendanceCommunicationSupportService(
$student,
$attendanceData,
$config,
$parents,
$composer
);
}
public function test_compose_returns_422_when_student_id_missing(): void
{
$service = $this->makeService();
$result = $service->compose(0);
$this->assertFalse($result['success']);
$this->assertSame(422, $result['status']);
}
public function test_parents_info_delegates_to_parent_lookup_service(): void
{
$parents = Mockery::mock(AttendanceParentLookupService::class);
$parents->shouldReceive('parentsInfo')
->once()
->with(20, '2025-2026')
->andReturn(['success' => true, 'data' => ['primary' => [], 'secondary' => []]]);
$service = $this->makeService(
parents: $parents
);
$result = $service->parentsInfo(20);
$this->assertTrue($result['success']);
}
}
@@ -0,0 +1,125 @@
<?php
namespace Tests\Unit\Services\AttendanceTracking;
use App\Models\AttendanceEmailTemplate;
use App\Services\AttendanceEmailComposerService;
use Mockery;
use Tests\TestCase;
class AttendanceEmailComposerServiceTest extends TestCase
{
protected function tearDown(): void
{
Mockery::close();
parent::tearDown();
}
public function test_build_template_context_returns_expected_placeholders(): void
{
$model = Mockery::mock(AttendanceEmailTemplate::class);
$service = new AttendanceEmailComposerService($model);
$context = $service->buildTemplateContext(
[
'name' => 'John Doe',
'last_date' => '2026-03-01',
],
[
'parent_name' => 'Jane Doe',
'phone' => '555-1212',
]
);
$this->assertSame('Jane Doe', $context['{{parent_name}}']);
$this->assertSame('John Doe', $context['{{student_name}}']);
$this->assertSame('2026-03-01', $context['{{incident_date}}']);
$this->assertSame('555-1212', $context['{{voicemail_phone}}']);
}
public function test_render_template_uses_model_get_template(): void
{
$model = Mockery::mock(AttendanceEmailTemplate::class);
$model->shouldReceive('getTemplate')
->once()
->with('ABS_1', 'default')
->andReturn([
'subject' => 'Notice for {{student_name}}',
'body_html' => '<p>Date: {{incident_date}}</p>',
]);
$service = new AttendanceEmailComposerService($model);
$rendered = $service->renderTemplate('ABS_1', 'default', [
'{{student_name}}' => 'John Doe',
'{{incident_date}}' => '2026-03-01',
]);
$this->assertSame(
['Notice for John Doe', '<p>Date: 2026-03-01</p>'],
$rendered
);
}
public function test_pick_variant_returns_override_when_present(): void
{
$model = Mockery::mock(AttendanceEmailTemplate::class);
$service = new AttendanceEmailComposerService($model);
$this->assertSame('custom_variant', $service->pickVariant('ABS_2', 'custom_variant'));
}
public function test_pick_variant_returns_no_answer_for_abs_2(): void
{
$model = Mockery::mock(AttendanceEmailTemplate::class);
$service = new AttendanceEmailComposerService($model);
$this->assertSame('no_answer', $service->pickVariant('ABS_2'));
}
public function test_pick_variant_returns_default_for_other_codes(): void
{
$model = Mockery::mock(AttendanceEmailTemplate::class);
$service = new AttendanceEmailComposerService($model);
$this->assertSame('default', $service->pickVariant('ABS_1'));
}
public function test_normalize_body_to_html_keeps_html_when_tags_present(): void
{
$model = Mockery::mock(AttendanceEmailTemplate::class);
$service = new AttendanceEmailComposerService($model);
$body = '<p>Hello</p>';
$this->assertSame($body, $service->normalizeBodyToHtml($body));
}
public function test_normalize_body_to_html_converts_plain_text_to_br_html(): void
{
$model = Mockery::mock(AttendanceEmailTemplate::class);
$service = new AttendanceEmailComposerService($model);
$result = $service->normalizeBodyToHtml("Hello\nWorld");
$this->assertStringContainsString('Hello', $result);
$this->assertStringContainsString('<br', $result);
$this->assertStringContainsString('World', $result);
}
public function test_build_fallback_auto_email_returns_subject_and_body(): void
{
$model = Mockery::mock(AttendanceEmailTemplate::class);
$service = new AttendanceEmailComposerService($model);
[$subject, $body] = $service->buildFallbackAutoEmail('ABS_1', [
'name' => 'John Doe',
'violation' => '1 Unreported Absence',
'last_date' => '2026-03-01',
], 'Jane Doe');
$this->assertSame('Attendance Notice: Unreported Absence', $subject);
$this->assertStringContainsString('John Doe', $body);
$this->assertStringContainsString('2026-03-01', $body);
$this->assertStringContainsString('Jane Doe', $body);
}
}
@@ -0,0 +1,46 @@
<?php
namespace Tests\Unit\Services\AttendanceTracking;
use App\Models\ParentNotification;
use App\Services\AttendanceNotificationLogService;
use Mockery;
use Tests\TestCase;
class AttendanceNotificationLogServiceTest extends TestCase
{
protected function tearDown(): void
{
Mockery::close();
parent::tearDown();
}
public function test_notification_already_sent_returns_true_when_model_has_sent_returns_true(): void
{
$model = Mockery::mock(ParentNotification::class);
$model->shouldReceive('hasSent')
->once()
->with(10, 'ABS_1', '2026-03-01', 'email', 'parent@example.com')
->andReturn(true);
$service = new AttendanceNotificationLogService($model);
$this->assertTrue(
$service->notificationAlreadySent(10, 'ABS_1', '2026-03-01', 'email', 'parent@example.com')
);
}
public function test_notification_already_sent_returns_false_when_model_has_sent_returns_false(): void
{
$model = Mockery::mock(ParentNotification::class);
$model->shouldReceive('hasSent')
->once()
->andReturn(false);
$service = new AttendanceNotificationLogService($model);
$this->assertFalse(
$service->notificationAlreadySent(10, 'ABS_1', '2026-03-01', 'email', 'parent@example.com')
);
}
}
@@ -0,0 +1,115 @@
<?php
namespace Tests\Unit\Services\AttendanceTracking;
use App\Models\AttendanceData;
use App\Models\AttendanceTracking;
use App\Models\Configuration;
use App\Models\Student;
use App\Models\StudentClass;
use App\Services\AttendanceEmailComposerService;
use App\Services\AttendanceMailerService;
use App\Services\AttendanceNotificationLogService;
use App\Services\AttendanceNotificationWorkflowService;
use App\Services\AttendanceParentLookupService;
use App\Services\ViolationRuleEngineService;
use Mockery;
use Tests\TestCase;
class AttendanceNotificationWorkflowServiceTest extends TestCase
{
protected function tearDown(): void
{
Mockery::close();
parent::tearDown();
}
protected function makeService(
?Student $student = null,
?StudentClass $studentClass = null,
?AttendanceData $attendanceData = null,
?AttendanceTracking $tracking = null,
?Configuration $config = null,
?AttendanceMailerService $mailer = null,
?ViolationRuleEngineService $rules = null,
?AttendanceParentLookupService $parents = null,
?AttendanceEmailComposerService $composer = null,
?AttendanceNotificationLogService $log = null
): AttendanceNotificationWorkflowService {
$student ??= Mockery::mock(Student::class);
$studentClass ??= Mockery::mock(StudentClass::class);
$attendanceData ??= Mockery::mock(AttendanceData::class);
$tracking ??= Mockery::mock(AttendanceTracking::class);
$config ??= Mockery::mock(Configuration::class);
$mailer ??= Mockery::mock(AttendanceMailerService::class);
$rules ??= Mockery::mock(ViolationRuleEngineService::class);
$parents ??= Mockery::mock(AttendanceParentLookupService::class);
$composer ??= Mockery::mock(AttendanceEmailComposerService::class);
$log ??= Mockery::mock(AttendanceNotificationLogService::class);
$config->shouldReceive('getConfig')->with('semester')->andReturn('Fall');
$config->shouldReceive('getConfig')->with('school_year')->andReturn('2025-2026');
return new AttendanceNotificationWorkflowService(
$student,
$studentClass,
$attendanceData,
$tracking,
$config,
$mailer,
$rules,
$parents,
$composer,
$log
);
}
public function test_send_auto_emails_returns_empty_summary_when_no_auto_email_violations(): void
{
$service = $this->makeService();
$result = $service->sendAutoEmails([
['action' => 'team_notify'],
]);
$this->assertTrue($result['success']);
$this->assertSame(0, $result['sent']);
$this->assertSame(0, $result['skipped']);
$this->assertSame(0, $result['errors']);
}
public function test_send_email_manual_returns_failure_when_all_sends_fail(): void
{
$mailer = Mockery::mock(AttendanceMailerService::class);
$mailer->shouldReceive('send')->andReturn(false);
$parents = Mockery::mock(AttendanceParentLookupService::class);
$parents->shouldReceive('getSecondaryParentForStudent')->andReturn(null);
$composer = Mockery::mock(AttendanceEmailComposerService::class);
$composer->shouldReceive('normalizeBodyToHtml')->andReturn('<p>Hello</p>');
$composer->shouldReceive('renderWithEmailLayout')->andReturn('<html>Hello</html>');
$log = Mockery::mock(AttendanceNotificationLogService::class);
$log->shouldReceive('logNotification')->atLeast()->once();
$service = $this->makeService(
mailer: $mailer,
parents: $parents,
composer: $composer,
log: $log
);
$result = $service->sendEmailManual([
'student_id' => 10,
'to' => 'parent@example.com',
'subject' => 'Test',
'body_html' => 'Hello',
'code' => 'ABS_1',
'incident_date' => '2026-03-01',
], ['2026-03-01']);
$this->assertFalse($result['success']);
$this->assertSame(500, $result['status']);
}
}
@@ -0,0 +1,96 @@
<?php
namespace Tests\Unit\Services\AttendanceTracking;
use App\Services\AttendanceParentLookupService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Tests\TestCase;
class AttendanceParentLookupServiceTest extends TestCase
{
use RefreshDatabase;
public function test_get_primary_parent_for_student_returns_parent_info(): void
{
DB::table('users')->insert([
'id' => 100,
'firstname' => 'Jane',
'lastname' => 'Doe',
'email' => 'jane@example.com',
'cellphone' => '555-1111',
]);
DB::table('students')->insert([
'id' => 200,
'parent_id' => 100,
]);
$service = new AttendanceParentLookupService();
$result = $service->getPrimaryParentForStudent(200);
$this->assertNotNull($result);
$this->assertSame(100, $result['user_id']);
$this->assertSame('jane@example.com', $result['email']);
$this->assertSame('Jane Doe', $result['parent_name']);
}
public function test_get_secondary_parent_for_student_returns_secondary_user_when_present(): void
{
DB::table('users')->insert([
['id' => 100, 'firstname' => 'Jane', 'lastname' => 'Doe', 'email' => 'jane@example.com', 'cellphone' => '555-1111'],
['id' => 101, 'firstname' => 'John', 'lastname' => 'Doe', 'email' => 'john@example.com', 'cellphone' => '555-2222'],
]);
DB::table('students')->insert([
'id' => 200,
'parent_id' => 100,
'school_year' => '2025-2026',
]);
DB::table('parents')->insert([
'firstparent_id' => 100,
'secondparent_id' => 101,
'school_year' => '2025-2026',
'updated_at' => now(),
]);
$service = new AttendanceParentLookupService();
$result = $service->getSecondaryParentForStudent(200, '2025-2026');
$this->assertNotNull($result);
$this->assertSame(101, $result['user_id']);
$this->assertSame('john@example.com', $result['email']);
}
public function test_parents_info_returns_primary_and_secondary_data(): void
{
DB::table('users')->insert([
['id' => 100, 'firstname' => 'Jane', 'lastname' => 'Doe', 'email' => 'jane@example.com', 'cellphone' => '555-1111'],
['id' => 101, 'firstname' => 'John', 'lastname' => 'Doe', 'email' => 'john@example.com', 'cellphone' => '555-2222'],
]);
DB::table('students')->insert([
'id' => 200,
'parent_id' => 100,
'school_year' => '2025-2026',
]);
DB::table('parents')->insert([
'firstparent_id' => 100,
'secondparent_id' => 101,
'school_year' => '2025-2026',
'updated_at' => now(),
]);
$service = new AttendanceParentLookupService();
$result = $service->parentsInfo(200, '2025-2026');
$this->assertTrue($result['success']);
$this->assertSame('jane@example.com', $result['data']['primary']['email']);
$this->assertSame('john@example.com', $result['data']['secondary']['email']);
}
}
@@ -0,0 +1,50 @@
<?php
namespace Tests\Unit\Services\AttendanceTracking;
use App\Models\AttendanceData;
use App\Services\AttendancePendingViolationService;
use App\Services\AttendanceViolationStudentResolverService;
use App\Services\ViolationRuleEngineService;
use Mockery;
use Tests\TestCase;
class AttendancePendingViolationServiceTest extends TestCase
{
protected function tearDown(): void
{
Mockery::close();
parent::tearDown();
}
public function test_get_pending_violations_returns_error_when_no_student_identifiers_found(): void
{
$attendanceData = Mockery::mock(AttendanceData::class);
$rules = Mockery::mock(ViolationRuleEngineService::class);
$resolver = Mockery::mock(AttendanceViolationStudentResolverService::class);
$resolver->shouldReceive('resolveForSchoolYear')
->once()
->with('2025-2026', null)
->andReturn([
'school_year' => '2025-2026',
'semester' => null,
'student_ids' => [],
'student_codes' => [],
'students' => [],
'student_code_to_id' => [],
'existing_ids' => [],
'debug' => [
'class_students' => 0,
'student_ids' => 0,
],
]);
$service = new AttendancePendingViolationService($attendanceData, $rules, $resolver);
$result = $service->getPendingViolations('2025-2026');
$this->assertSame([], $result['students']);
$this->assertSame('No student identifiers found for the current school year.', $result['error']);
}
}
@@ -0,0 +1,135 @@
<?php
namespace Tests\Unit\Services\AttendanceTracking;
use App\Models\AttendanceData;
use App\Models\AttendanceTracking;
use App\Models\Configuration;
use App\Models\Student;
use App\Models\StudentClass;
use App\Services\AttendanceCaseQueryService;
use App\Services\AttendanceCommunicationSupportService;
use App\Services\AttendanceEmailComposerService;
use App\Services\AttendanceMailerService;
use App\Services\AttendanceNotificationLogService;
use App\Services\AttendanceNotificationWorkflowService;
use App\Services\AttendanceParentLookupService;
use App\Services\AttendancePendingViolationService;
use App\Services\AttendanceTrackingService;
use App\Services\AttendanceViolationStudentResolverService;
use App\Services\ViolationRuleEngineService;
use Mockery;
use Tests\TestCase;
class AttendanceTrackingServiceTest extends TestCase
{
protected function tearDown(): void
{
Mockery::close();
parent::tearDown();
}
protected function makeService(
?AttendancePendingViolationService $pending = null,
?AttendanceCaseQueryService $caseQuery = null,
?AttendanceNotificationWorkflowService $workflow = null,
?AttendanceCommunicationSupportService $support = null
): AttendanceTrackingService {
$student = Mockery::mock(Student::class);
$studentClass = Mockery::mock(StudentClass::class);
$attendanceData = Mockery::mock(AttendanceData::class);
$tracking = Mockery::mock(AttendanceTracking::class);
$config = Mockery::mock(Configuration::class);
$mailer = Mockery::mock(AttendanceMailerService::class);
$rules = Mockery::mock(ViolationRuleEngineService::class);
$parents = Mockery::mock(AttendanceParentLookupService::class);
$composer = Mockery::mock(AttendanceEmailComposerService::class);
$log = Mockery::mock(AttendanceNotificationLogService::class);
$resolver = Mockery::mock(AttendanceViolationStudentResolverService::class);
$pending ??= Mockery::mock(AttendancePendingViolationService::class);
$caseQuery ??= Mockery::mock(AttendanceCaseQueryService::class);
$workflow ??= Mockery::mock(AttendanceNotificationWorkflowService::class);
$support ??= Mockery::mock(AttendanceCommunicationSupportService::class);
$config->shouldReceive('getConfig')->with('semester')->andReturn('Fall');
$config->shouldReceive('getConfig')->with('school_year')->andReturn('2025-2026');
return new AttendanceTrackingService(
$student,
$studentClass,
$attendanceData,
$tracking,
$config,
$mailer,
$rules,
$parents,
$composer,
$log,
$resolver,
$caseQuery,
$workflow,
$support,
$pending
);
}
public function test_get_pending_violations_delegates_to_pending_service(): void
{
$pending = Mockery::mock(AttendancePendingViolationService::class);
$pending->shouldReceive('getPendingViolations')
->once()
->with('2025-2026', null, null)
->andReturn(['students' => []]);
$service = $this->makeService(pending: $pending);
$result = $service->getPendingViolations();
$this->assertSame(['students' => []], $result);
}
public function test_get_student_case_delegates_to_case_query_service(): void
{
$caseQuery = Mockery::mock(AttendanceCaseQueryService::class);
$caseQuery->shouldReceive('getStudentCase')
->once()
->andReturn(['success' => true]);
$service = $this->makeService(caseQuery: $caseQuery);
$result = $service->getStudentCase(1);
$this->assertTrue($result['success']);
}
public function test_record_delegates_to_workflow_service(): void
{
$workflow = Mockery::mock(AttendanceNotificationWorkflowService::class);
$workflow->shouldReceive('record')
->once()
->with(['student_id' => 10])
->andReturn(['success' => true]);
$service = $this->makeService(workflow: $workflow);
$result = $service->record(['student_id' => 10]);
$this->assertTrue($result['success']);
}
public function test_compose_delegates_to_support_service(): void
{
$support = Mockery::mock(AttendanceCommunicationSupportService::class);
$support->shouldReceive('compose')
->once()
->with(10, 'ABS_1', 'default', null)
->andReturn(['success' => true]);
$service = $this->makeService(support: $support);
$result = $service->compose(10);
$this->assertTrue($result['success']);
}
}
@@ -0,0 +1,68 @@
<?php
namespace Tests\Unit\Services\AttendanceTracking;
use App\Models\AttendanceData;
use App\Models\Student;
use App\Models\StudentClass;
use App\Services\AttendanceViolationStudentResolverService;
use Mockery;
use Tests\TestCase;
class AttendanceViolationStudentResolverServiceTest extends TestCase
{
protected function tearDown(): void
{
Mockery::close();
parent::tearDown();
}
public function test_ensure_attendance_student_exists_keeps_numeric_student(): void
{
$student = Mockery::mock(Student::class);
$studentClass = Mockery::mock(StudentClass::class);
$attendanceData = Mockery::mock(AttendanceData::class);
$service = new AttendanceViolationStudentResolverService($student, $studentClass, $attendanceData);
$students = [];
$studentCodeToId = [];
$existingIds = [];
$sid = $service->ensureAttendanceStudentExists(
$students,
$studentCodeToId,
$existingIds,
123
);
$this->assertSame(123, $sid);
$this->assertCount(1, $students);
$this->assertSame(123, $students[0]['id']);
}
public function test_ensure_attendance_student_exists_creates_placeholder_for_code(): void
{
$student = Mockery::mock(Student::class);
$studentClass = Mockery::mock(StudentClass::class);
$attendanceData = Mockery::mock(AttendanceData::class);
$service = new AttendanceViolationStudentResolverService($student, $studentClass, $attendanceData);
$students = [];
$studentCodeToId = [];
$existingIds = [];
$sid = $service->ensureAttendanceStudentExists(
$students,
$studentCodeToId,
$existingIds,
'SCH-001'
);
$this->assertNotNull($sid);
$this->assertCount(1, $students);
$this->assertSame('SCH-001', $students[0]['school_id']);
$this->assertArrayHasKey('SCH-001', $studentCodeToId);
}
}
@@ -0,0 +1,112 @@
<?php
namespace Tests\Unit\Services\AttendanceTracking;
use App\Models\AttendanceTracking;
use App\Services\AttendanceParentLookupService;
use App\Services\ViolationRuleEngineService;
use Mockery;
use Tests\TestCase;
class ViolationRuleEngineServiceTest extends TestCase
{
protected function tearDown(): void
{
Mockery::close();
parent::tearDown();
}
public function test_choose_higher_severity_returns_higher_severity_rule(): void
{
$tracking = Mockery::mock(AttendanceTracking::class);
$parents = Mockery::mock(AttendanceParentLookupService::class);
$service = new ViolationRuleEngineService($tracking, $parents);
$result = $service->chooseHigherSeverity(
['severity' => 2, 'action' => 'team_notify'],
['severity' => 4, 'action' => 'auto_email']
);
$this->assertSame(4, $result['severity']);
}
public function test_choose_higher_severity_breaks_ties_by_action_priority(): void
{
$tracking = Mockery::mock(AttendanceTracking::class);
$parents = Mockery::mock(AttendanceParentLookupService::class);
$service = new ViolationRuleEngineService($tracking, $parents);
$result = $service->chooseHigherSeverity(
['severity' => 3, 'action' => 'team_notify'],
['severity' => 3, 'action' => 'auto_email']
);
$this->assertSame('team_notify', $result['action']);
}
public function test_window_weeks_for_violation_code_returns_expected_values(): void
{
$tracking = Mockery::mock(AttendanceTracking::class);
$parents = Mockery::mock(AttendanceParentLookupService::class);
$service = new ViolationRuleEngineService($tracking, $parents);
$this->assertSame(3, $service->windowWeeksForViolationCode('ABS_2'));
$this->assertSame(4, $service->windowWeeksForViolationCode('ABS_3'));
$this->assertSame(5, $service->windowWeeksForViolationCode('ABS_4'));
$this->assertSame(4, $service->windowWeeksForViolationCode('LATE_4'));
}
public function test_has_n_consecutive_items_detects_weekly_sequence(): void
{
$tracking = Mockery::mock(AttendanceTracking::class);
$parents = Mockery::mock(AttendanceParentLookupService::class);
$service = new ViolationRuleEngineService($tracking, $parents);
$result = $service->hasNConsecutiveItems(
['2026-03-15', '2026-03-08', '2026-03-01'],
3,
7
);
$this->assertTrue($result);
}
public function test_has_n_in_w_active_weeks_detects_window_match(): void
{
$tracking = Mockery::mock(AttendanceTracking::class);
$parents = Mockery::mock(AttendanceParentLookupService::class);
$service = new ViolationRuleEngineService($tracking, $parents);
$this->assertTrue($service->hasNInWActiveWeeks([0, 1], 2, 3));
$this->assertFalse($service->hasNInWActiveWeeks([0], 2, 3));
}
public function test_two_lates_one_abs_in_w_weeks_detects_mix_rule(): void
{
$tracking = Mockery::mock(AttendanceTracking::class);
$parents = Mockery::mock(AttendanceParentLookupService::class);
$service = new ViolationRuleEngineService($tracking, $parents);
$this->assertTrue($service->twoLatesOneAbsInWWeeks([1, 2], [3], 4));
$this->assertFalse($service->twoLatesOneAbsInWWeeks([1], [3], 4));
}
public function test_derive_school_year_bounds_returns_expected_dates(): void
{
$tracking = Mockery::mock(AttendanceTracking::class);
$parents = Mockery::mock(AttendanceParentLookupService::class);
$service = new ViolationRuleEngineService($tracking, $parents);
[$start, $end] = $service->deriveSchoolYearBounds('2025-2026');
$this->assertSame('2025-08-01', $start);
$this->assertSame('2026-07-31', $end);
}
}
@@ -0,0 +1,94 @@
<?php
namespace Tests\Unit\Services\Badges;
use App\Services\Badges\BadgeFormDataService;
use App\Services\Badges\BadgeTextFormatter;
use App\Services\Badges\BadgeUserLookupService;
use Mockery;
use Tests\TestCase;
class BadgeFormDataServiceTest extends TestCase
{
protected function tearDown(): void
{
Mockery::close();
parent::tearDown();
}
public function test_build_formats_users_and_assigns_latest_class_for_teacherish_roles(): void
{
$lookup = Mockery::mock(BadgeUserLookupService::class);
$formatter = new BadgeTextFormatter();
$lookup->shouldReceive('fetchStaffList')
->once()
->with('2025-2026', [10, 20])
->andReturn([
[
'id' => 10,
'firstname' => 'John',
'lastname' => 'Doe',
'roles' => 'teacher_assistant,admin',
],
[
'id' => 20,
'firstname' => 'Sara',
'lastname' => 'Smith',
'roles' => 'staff',
],
]);
$lookup->shouldReceive('getLatestClassForUser')
->once()
->with(10, '2025-2026')
->andReturn([
'class_section_id' => 5,
'class_section_name' => '3-A',
]);
$lookup->shouldReceive('getAvailableSchoolYears')
->once()
->andReturn(['2025-2026', '2024-2025']);
$service = new BadgeFormDataService($lookup, $formatter);
$result = $service->build('2025-2026', [10, 20], 'teacher');
$this->assertSame('2025-2026', $result['selectedYear']);
$this->assertSame([10, 20], $result['selectedUserIds']);
$this->assertSame('teacher', $result['active_role']);
$this->assertCount(2, $result['users']);
$this->assertSame(10, $result['users'][0]['user_id']);
$this->assertSame('Teacher Assistant, Admin', $result['users'][0]['roles']);
$this->assertSame('teacher_assistant', $result['users'][0]['role_name_raw']);
$this->assertSame('Teacher Assistant', $result['users'][0]['role_name']);
$this->assertSame(5, $result['users'][0]['class_section_id']);
$this->assertSame('3-A', $result['users'][0]['class_section_name']);
$this->assertSame(20, $result['users'][1]['user_id']);
$this->assertSame('Staff', $result['users'][1]['roles']);
$this->assertNull($result['users'][1]['class_section_id']);
}
public function test_build_defaults_invalid_active_role_to_teacher(): void
{
$lookup = Mockery::mock(BadgeUserLookupService::class);
$formatter = new BadgeTextFormatter();
$lookup->shouldReceive('fetchStaffList')
->once()
->andReturn([]);
$lookup->shouldReceive('getAvailableSchoolYears')
->once()
->andReturn([]);
$service = new BadgeFormDataService($lookup, $formatter);
$result = $service->build('2025-2026', [], 'invalid-role');
$this->assertSame('teacher', $result['active_role']);
}
}
@@ -0,0 +1,132 @@
<?php
namespace Tests\Unit\Services\Badges;
use App\Services\Badges\BadgePdfService;
use App\Services\Badges\BadgePrintLogService;
use App\Services\Badges\BadgeTextFormatter;
use App\Services\Badges\BadgeUserLookupService;
use Mockery;
use Symfony\Component\HttpFoundation\Response as SymfonyResponse;
use Tests\TestCase;
class BadgePdfServiceTest extends TestCase
{
protected function tearDown(): void
{
Mockery::close();
parent::tearDown();
}
public function test_generate_returns_pdf_response_and_logs_prints(): void
{
$lookup = Mockery::mock(BadgeUserLookupService::class);
$printLog = Mockery::mock(BadgePrintLogService::class);
$formatter = new BadgeTextFormatter();
$lookup->shouldReceive('getUserInfoById')
->once()
->with(10, '2025-2026')
->andReturn([
'user_id' => 10,
'name' => 'John Doe',
'role' => 'Teacher',
'roles' => 'Teacher',
'role_name_raw' => 'teacher',
'roles_raw' => 'teacher',
'class_section_name' => '3-A',
'school_name' => 'Al Rahma Sunday School',
'school_logo' => null,
'isgl_logo' => null,
'school_year' => '2025-2026',
]);
$printLog->shouldReceive('logSafely')
->once()
->with([10], 1, '2025-2026', [], [], 1);
$service = new BadgePdfService($lookup, $printLog, $formatter);
$response = $service->generate(
userIds: [10],
schoolYear: '2025-2026',
rolesMap: [],
classesMap: [],
actorId: 1
);
$this->assertInstanceOf(SymfonyResponse::class, $response);
$this->assertSame('application/pdf', $response->headers->get('Content-Type'));
$this->assertStringContainsString('inline; filename="Staff_Badges.pdf"', $response->headers->get('Content-Disposition'));
$this->assertNotEmpty($response->getContent());
}
public function test_generate_returns_empty_state_pdf_when_no_valid_users_found(): void
{
$lookup = Mockery::mock(BadgeUserLookupService::class);
$printLog = Mockery::mock(BadgePrintLogService::class);
$formatter = new BadgeTextFormatter();
$lookup->shouldReceive('getUserInfoById')
->once()
->with(99, null)
->andReturn(null);
$printLog->shouldReceive('logSafely')
->once()
->with([99], null, null, [], [], 1);
$service = new BadgePdfService($lookup, $printLog, $formatter);
$response = $service->generate(
userIds: [99],
schoolYear: null,
rolesMap: [],
classesMap: [],
actorId: null
);
$this->assertSame('application/pdf', $response->headers->get('Content-Type'));
$this->assertNotEmpty($response->getContent());
}
public function test_generate_deduplicates_same_badge_content(): void
{
$lookup = Mockery::mock(BadgeUserLookupService::class);
$printLog = Mockery::mock(BadgePrintLogService::class);
$formatter = new BadgeTextFormatter();
$userInfo = [
'user_id' => 10,
'name' => 'John Doe',
'role' => 'Teacher',
'roles' => 'Teacher',
'role_name_raw' => 'teacher',
'roles_raw' => 'teacher',
'class_section_name' => '3-A',
'school_name' => 'Al Rahma Sunday School',
'school_logo' => null,
'isgl_logo' => null,
'school_year' => '2025-2026',
];
$lookup->shouldReceive('getUserInfoById')->twice()->andReturn($userInfo);
$printLog->shouldReceive('logSafely')
->once()
->with([10], 7, '2025-2026', [], [], 1);
$service = new BadgePdfService($lookup, $printLog, $formatter);
$response = $service->generate(
userIds: [10, 10],
schoolYear: '2025-2026',
rolesMap: [],
classesMap: [],
actorId: 7
);
$this->assertSame(200, $response->getStatusCode());
$this->assertSame('application/pdf', $response->headers->get('Content-Type'));
}
}
@@ -0,0 +1,94 @@
<?php
namespace Tests\Unit\Services\Badges;
use App\Models\BadgePrintLog;
use App\Services\Badges\BadgePrintLogService;
use App\Services\Badges\BadgeTextFormatter;
use Illuminate\Support\Facades\Log;
use Mockery;
use Tests\TestCase;
class BadgePrintLogServiceTest extends TestCase
{
protected function tearDown(): void
{
Mockery::close();
parent::tearDown();
}
public function test_get_status_normalizes_ids_before_calling_model(): void
{
$model = Mockery::mock(BadgePrintLog::class);
$formatter = new BadgeTextFormatter();
$expected = [
5 => ['count' => 2, 'last_printed_at' => '2026-03-06 10:00:00', 'last_printed_by' => 1],
];
$model->shouldReceive('getStatus')
->once()
->with([5, 7], '2025-2026')
->andReturn($expected);
$service = new BadgePrintLogService($model, $formatter);
$result = $service->getStatus(['5', '7', '5', ''], '2025-2026');
$this->assertSame($expected, $result);
}
public function test_log_normalizes_ids_and_returns_inserted_count(): void
{
$model = Mockery::mock(BadgePrintLog::class);
$formatter = new BadgeTextFormatter();
$model->shouldReceive('logPrints')
->once()
->with(
[10, 20],
99,
'2025-2026',
['10' => 'Teacher'],
['10' => '3-A'],
1
)
->andReturn(2);
$service = new BadgePrintLogService($model, $formatter);
$result = $service->log(
userIds: ['10', '20', '10'],
actorId: 99,
schoolYear: '2025-2026',
rolesMap: ['10' => 'Teacher'],
classesMap: ['10' => '3-A'],
copies: 1
);
$this->assertSame(2, $result);
}
public function test_log_safely_swallows_exceptions_and_logs_error(): void
{
Log::spy();
$model = Mockery::mock(BadgePrintLog::class);
$formatter = new BadgeTextFormatter();
$model->shouldReceive('logPrints')
->once()
->andThrow(new \RuntimeException('table missing'));
$service = new BadgePrintLogService($model, $formatter);
$service->logSafely(
userIds: [1, 2],
actorId: 5,
schoolYear: '2025-2026'
);
Log::shouldHaveReceived('error')->once();
$this->assertTrue(true);
}
}
@@ -0,0 +1,98 @@
<?php
namespace Tests\Unit\Services\Badges;
use App\Services\Badges\BadgeTextFormatter;
use FPDF;
use Tests\TestCase;
class BadgeTextFormatterTest extends TestCase
{
protected BadgeTextFormatter $formatter;
protected function setUp(): void
{
parent::setUp();
$this->formatter = new BadgeTextFormatter();
}
public function test_normalize_ids_filters_and_deduplicates(): void
{
$result = $this->formatter->normalizeIds([
' 5 ',
'',
null,
'7',
5,
'0',
-1,
'12',
]);
$this->assertSame([5, 7, 12], $result);
}
public function test_norm_removes_extra_spaces_and_control_chars(): void
{
$input = " Head \x07 of School ";
$result = $this->formatter->norm($input);
$this->assertSame('Head of School', $result);
}
public function test_format_role_handles_special_words_and_acronyms(): void
{
$this->assertSame('Head of School', $this->formatter->formatRole('HEAD_OF_SCHOOL'));
$this->assertSame('Teacher Assistant', $this->formatter->formatRole('teacher_assistant'));
$this->assertSame('HR Manager', $this->formatter->formatRole('hr manager'));
$this->assertSame('PTA Head', $this->formatter->formatRole('pta head'));
}
public function test_format_roles_csv_formats_each_role(): void
{
$result = $this->formatter->formatRolesCsv('teacher_assistant, head_of_school, hr');
$this->assertSame('Teacher Assistant, Head of School, HR', $result);
}
public function test_format_class_normalizes_known_values(): void
{
$this->assertSame('KG', $this->formatter->formatClass('kindergarten'));
$this->assertSame('KG', $this->formatter->formatClass('kg'));
$this->assertSame('Youth', $this->formatter->formatClass('youth'));
$this->assertSame('3-A', $this->formatter->formatClass('3-A'));
}
public function test_resolve_role_prefers_first_available_candidate(): void
{
$info = [
'role_name' => '',
'roles' => 'teacher_assistant, admin',
'job_title' => 'ignored because roles wins first',
];
$result = $this->formatter->resolveRole($info);
$this->assertSame('teacher_assistant', $result);
}
public function test_to_pdf_returns_string(): void
{
$result = $this->formatter->toPdf('Al Rahma Sunday School');
$this->assertIsString($result);
$this->assertNotSame('', $result);
}
public function test_fit_text_returns_size_within_bounds(): void
{
$pdf = new FPDF();
$pdf->AddPage();
$pdf->SetFont('Arial', '', 12);
$size = $this->formatter->fitText($pdf, 'Very Long Badge Label Text', 11, 7, 50);
$this->assertGreaterThanOrEqual(7, $size);
$this->assertLessThanOrEqual(11, $size);
}
}