update tests

This commit is contained in:
root
2026-06-08 22:06:30 -04:00
parent 79024235ef
commit 60ecacb7f8
54 changed files with 13243 additions and 5561 deletions
@@ -12,6 +12,7 @@ use App\Services\Auth\AuthSessionService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use PHPOpenSourceSaver\JWTAuth\Facades\JWTAuth;
/**
* Session (cookie) endpoints for SPA — JSON alongside JWT on /api/login.
@@ -141,12 +142,13 @@ class AuthSessionController extends Controller
], 403);
}
return response()->json([
'status' => true,
$loginPayload = $this->security->buildLoginResponse($fresh, JWTAuth::fromUser($fresh));
return response()->json(array_merge($loginPayload, [
'requires_role_selection' => ($dest['kind'] ?? '') === 'select_role',
'roles' => $dest['roles'] ?? null,
'next_url' => $dest['redirect_url'] ?? $this->urls->docsHomeUrl(false),
]);
]));
}
/** Closes the current web session. */
+31 -2
View File
@@ -3,6 +3,7 @@
namespace App\Models;
use App\Models\BaseModel;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\SoftDeletes;
class Notification extends BaseModel
@@ -30,10 +31,38 @@ class Notification extends BaseModel
protected $casts = [
'scheduled_at' => 'datetime',
'expires_at' => 'datetime',
// If delivery_channels is JSON in DB:
'delivery_channels' => 'array',
];
/**
* MySQL stores channels as SET('in_app','email','sms'); expose as array in PHP.
*/
protected function deliveryChannels(): Attribute
{
return Attribute::make(
get: static function (?string $value): array {
if ($value === null || $value === '') {
return [];
}
$trimmed = trim($value);
if (str_starts_with($trimmed, '[')) {
$decoded = json_decode($trimmed, true);
return is_array($decoded) ? array_values($decoded) : [];
}
return array_values(array_filter(array_map('trim', explode(',', $value))));
},
set: static function ($value): string {
if (is_array($value)) {
return implode(',', array_values(array_filter(array_map('strval', $value))));
}
return (string) $value;
}
);
}
/**
* Equivalent of legacy getActiveNotifications()
* Active = scheduled_at <= now AND (expires_at is null OR expires_at > now)
+2 -2
View File
@@ -9,8 +9,8 @@ class PaymentTransaction extends BaseModel
{
protected $table = 'payment_transactions';
// ✅ legacy: useTimestamps = true (created_at/updated_at)
public $timestamps = true;
// Legacy table has no created_at/updated_at columns.
public $timestamps = false;
protected $fillable = [
'transaction_id',
+1 -1
View File
@@ -13,7 +13,7 @@ class StudentAllergy extends BaseModel
/**
* legacy: timestamps disabled
*/
public $timestamps = true;
public $timestamps = false;
protected $fillable = [
'student_id',
+1 -1
View File
@@ -13,7 +13,7 @@ class StudentMedicalCondition extends BaseModel
/**
* legacy: timestamps disabled
*/
public $timestamps = true;
public $timestamps = false;
protected $fillable = [
'student_id',
@@ -9,6 +9,7 @@ use App\Models\TeacherClass;
use Carbon\Carbon;
use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use RuntimeException;
class TeacherAttendanceSubmissionService
@@ -171,6 +172,20 @@ class TeacherAttendanceSubmissionService
$position = strtolower((string)($teacher['position'] ?? 'main'));
$position = $position === 'ta' ? 'ta' : 'main';
$staffPayload = [
'role_name' => 'Teacher',
'status' => strtolower((string)($teachersData[$teacherId]['status'] ?? 'present')),
'reason' => ($teachersData[$teacherId]['reason'] ?? null) ?: null,
'updated_at' => now(),
'created_at' => now(),
];
if (Schema::hasColumn('staff_attendance', 'class_section_id')) {
$staffPayload['class_section_id'] = $classSectionId;
}
if (Schema::hasColumn('staff_attendance', 'position')) {
$staffPayload['position'] = $position;
}
DB::table('staff_attendance')->updateOrInsert(
[
'user_id' => $teacherId,
@@ -178,15 +193,7 @@ class TeacherAttendanceSubmissionService
'semester' => $semester,
'school_year' => $schoolYear,
],
[
'role_name' => 'Teacher',
'class_section_id' => $classSectionId,
'position' => $position,
'status' => strtolower((string)($teachersData[$teacherId]['status'] ?? 'present')),
'reason' => ($teachersData[$teacherId]['reason'] ?? null) ?: null,
'updated_at' => now(),
'created_at' => now(),
]
$staffPayload
);
}
+10 -1
View File
@@ -129,7 +129,7 @@ class ApiLoginSecurityService
/**
* Top-level JSON body for API login success responses.
*
* @return array{status: true, token: string, user: array{id: int, name: string, roles: object, class_section_id: ?int, class_section_name: ?string}}
* @return array{status: true, token: string, access_token: string, token_type: string, expires_in: int, user: array{id: int, name: string, firstname: ?string, lastname: ?string, email: ?string, roles: object, class_section_id: ?int, class_section_name: ?string}}
*/
public function buildLoginResponse(User $user, string $jwtToken): array
{
@@ -139,10 +139,19 @@ class ApiLoginSecurityService
return [
'status' => true,
// Keep both names. Older clients read `token`; JWT-aware clients often read
// `access_token`. Removing either one just makes login fail in a new and
// irritatingly avoidable way.
'token' => $jwtToken,
'access_token' => $jwtToken,
'token_type' => 'bearer',
'expires_in' => (int) config('jwt.ttl') * 60,
'user' => [
'id' => (int) $user->id,
'name' => $fullName,
'firstname' => $user->firstname,
'lastname' => $user->lastname,
'email' => $user->email,
'roles' => $this->rolesToObjectMap($roleNames),
'class_section_id' => $teacherContext['class_section_id'],
'class_section_name' => $teacherContext['class_section_name'],
@@ -15,17 +15,17 @@ class ParentAttendanceReportCalendarService
*/
public function computeSundays(int $weeksBefore = 4, int $weeksAfter = 26): array
{
$today = new \DateTime('today');
$today = now()->startOfDay();
$dow = (int) $today->format('w');
$thisSunday = clone $today;
$thisSunday = $today->copy();
if ($dow !== 0) {
$thisSunday->modify('last sunday');
}
$default = clone $today;
$default = $today->copy();
if ($dow !== 0) {
$default->modify('next sunday');
$default = $default->copy()->modify('next sunday');
}
$schoolYear = (string) ($this->configService->context()['school_year'] ?? '');
@@ -33,13 +33,13 @@ class ParentAttendanceReportCalendarService
$dates = [];
for ($i = $weeksBefore; $i >= 1; $i--) {
$dates[] = (clone $thisSunday)->modify('-' . $i . ' week');
$dates[] = $thisSunday->copy()->modify('-' . $i . ' week');
}
$cursor = clone $thisSunday;
$cursor = $thisSunday->copy();
while ($cursor <= $cap) {
$dates[] = clone $cursor;
$cursor->modify('+1 week');
$dates[] = $cursor->copy();
$cursor = $cursor->copy()->modify('+1 week');
}
$rangeStart = reset($dates);
@@ -108,7 +108,7 @@ class ParentAttendanceReportCalendarService
public function firstSundayOfJune(string $schoolYear): \DateTime
{
$today = new \DateTime('today');
$today = now()->startOfDay();
$endYear = null;
if (preg_match('/^(\\d{4})\\D(\\d{4})$/', $schoolYear, $m)) {
$endYear = (int) $m[2];
@@ -118,10 +118,11 @@ class ParentAttendanceReportCalendarService
$endYear = ((int) $today->format('n') > 6) ? ($y + 1) : $y;
}
$juneFirst = new \DateTime(sprintf('%04d-06-01', $endYear));
$juneFirst = now()->parse(sprintf('%04d-06-01', $endYear))->startOfDay();
if ((int) $juneFirst->format('w') !== 0) {
$juneFirst->modify('next sunday');
$juneFirst = $juneFirst->copy()->modify('next sunday');
}
return $juneFirst;
}
}
@@ -82,6 +82,7 @@ class ParentRegistrationService
$emergencyContacts,
$schoolYear,
$semester,
$ageReference,
&$createdStudentIds,
&$skippedStudents
) {
@@ -16,7 +16,7 @@ class PaymentNotificationDispatchService
$notification = Notification::query()->create([
'title' => $title,
'message' => $message,
'target_group' => 'user',
'target_group' => 'everyone',
'delivery_channels' => $channels,
'priority' => 'normal',
'status' => 'sent',
+3 -1
View File
@@ -67,7 +67,9 @@ class SemesterRangeService
$lastDay = $this->configService->getLastSchoolDay();
try {
$target = new DateTimeImmutable($date ?: 'now');
$target = ($date !== null && $date !== '')
? new DateTimeImmutable($date)
: DateTimeImmutable::createFromInterface(now());
} catch (\Throwable) {
return '';
}
@@ -96,7 +96,7 @@ class TeacherDashboardService
->whereIn('tc.class_section_id', $classSectionIds)
->where('tc.school_year', $schoolYear)
->orderBy('tc.class_section_id')
->orderByRaw("FIELD(tc.position,'main','ta')")
->orderByRaw("CASE tc.position WHEN 'main' THEN 0 WHEN 'ta' THEN 1 ELSE 2 END")
->orderBy('u.firstname')
->get();
Vendored Executable → Regular
View File
Vendored Executable → Regular
View File
+21
View File
@@ -0,0 +1,21 @@
services:
test-db:
image: mysql:8.0
container_name: alrahma_test_db
command:
- --default-authentication-plugin=mysql_native_password
environment:
MYSQL_DATABASE: ${TEST_DB_DATABASE:-school_api_test}
MYSQL_USER: ${TEST_DB_USERNAME:-school}
MYSQL_PASSWORD: ${TEST_DB_PASSWORD:-school}
MYSQL_ROOT_PASSWORD: ${TEST_DB_ROOT_PASSWORD:-root}
ports:
- "${TEST_DB_PORT:-33106}:3306"
# Store data in RAM so the test database is fast and ephemeral.
tmpfs:
- /var/lib/mysql
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1", "-uroot", "-p${TEST_DB_ROOT_PASSWORD:-root}"]
interval: 3s
timeout: 3s
retries: 30
File diff suppressed because it is too large Load Diff
BIN
View File
Binary file not shown.
+24 -100
View File
@@ -1,9 +1,5 @@
# GitLab CI for Laravel 12
# Save this file as: .gitlab-ci.yml
default:
tags:
- runner1
# GitLab CI for Laravel
# GitLab reads this file automatically when it is committed at the repository root as `.gitlab-ci.yml`.
workflow:
rules:
@@ -15,150 +11,78 @@ workflow:
when: always
- if: '$CI_COMMIT_TAG'
when: always
- when: always
- when: never
stages:
- validate
- test
- build
- security
variables:
APP_ENV: testing
APP_DEBUG: "false"
LOG_CHANNEL: stderr
CACHE_DRIVER: array
SESSION_DRIVER: array
QUEUE_CONNECTION: sync
DB_CONNECTION: sqlite
DB_DATABASE: "$CI_PROJECT_DIR/database/database.sqlite"
CACHE_DRIVER: array
CACHE_STORE: array
SESSION_DRIVER: array
QUEUE_CONNECTION: sync
MAIL_MAILER: array
BROADCAST_CONNECTION: log
FILESYSTEM_DISK: local
COMPOSER_CACHE_DIR: "$CI_PROJECT_DIR/.composer-cache"
NPM_CONFIG_CACHE: "$CI_PROJECT_DIR/.npm-cache"
XDEBUG_MODE: coverage
cache:
key:
files:
- composer.lock
- package-lock.json
paths:
- .composer-cache/
- .npm-cache/
- vendor/
- node_modules/
.php_laravel_job:
phpunit:
image: php:8.3-cli
stage: test
before_script:
- apt-get update
- apt-get install -y --no-install-recommends git unzip curl libzip-dev libpng-dev libonig-dev libxml2-dev libsqlite3-dev
- docker-php-ext-install pdo pdo_sqlite mbstring zip gd
- apt-get install -y --no-install-recommends git unzip curl libzip-dev libpng-dev libonig-dev libxml2-dev libsqlite3-dev libcurl4-openssl-dev
- docker-php-ext-install curl dom gd mbstring pdo pdo_sqlite xml xmlwriter zip
- php -r "copy('https://getcomposer.org/installer', 'composer-setup.php');"
- php composer-setup.php --install-dir=/usr/local/bin --filename=composer
- rm composer-setup.php
- composer install --prefer-dist --no-interaction --no-progress
- cp .env.example .env || true
- |
cat > .env <<'EOF'
cat > .env <<EOF
APP_NAME=school_api
APP_ENV=testing
APP_KEY=
APP_DEBUG=false
APP_URL=http://localhost
LOG_CHANNEL=stderr
LOG_LEVEL=debug
DB_CONNECTION=sqlite
DB_DATABASE=${CI_PROJECT_DIR}/database/database.sqlite
DB_DATABASE=$CI_PROJECT_DIR/database/database.sqlite
CACHE_DRIVER=array
CACHE_STORE=array
SESSION_DRIVER=array
QUEUE_CONNECTION=sync
MAIL_MAILER=array
BROADCAST_CONNECTION=log
FILESYSTEM_DISK=local
JWT_SECRET=ci_dummy_secret_not_for_production
EOF
- mkdir -p database storage/framework/cache storage/framework/sessions storage/framework/views bootstrap/cache
- mkdir -p database storage/framework/cache storage/framework/sessions storage/framework/views storage/logs bootstrap/cache reports
- touch database/database.sqlite
- php artisan key:generate --force
- php artisan config:clear
composer_validate:
extends: .php_laravel_job
stage: validate
script:
- composer validate --strict
- php -v
- php artisan --version
laravel_pint:
extends: .php_laravel_job
stage: validate
script:
- vendor/bin/pint --test
phpunit:
extends: .php_laravel_job
stage: test
script:
- php artisan migrate --force
- php artisan test --testsuite=Feature --no-interaction
- php artisan test --testsuite=Unit --no-interaction
script:
- php artisan test --no-interaction --log-junit reports/phpunit.xml
artifacts:
when: always
expire_in: 7 days
reports:
junit: reports/phpunit.xml
paths:
- reports/
- storage/logs/
build_frontend:
image: node:22-alpine
stage: build
before_script:
- npm ci
script:
- npm run build
artifacts:
expire_in: 7 days
paths:
- public/build/
composer_audit:
extends: .php_laravel_job
stage: security
script:
- composer audit --locked --no-interaction
npm_audit:
image: node:22-alpine
stage: security
before_script:
- npm ci
script:
- npm audit --audit-level=high
allow_failure: true
secret_detection_basic:
image: alpine:3.20
stage: security
before_script:
- apk add --no-cache git grep
script:
- |
echo "Scanning for obvious committed secrets..."
! grep -RInE "(APP_KEY=base64:|DB_PASSWORD=.+|JWT_SECRET=.+|MAIL_PASSWORD=.+|PAYPAL_(CLIENT|SECRET|MODE)|AKIA[0-9A-Z]{16}|-----BEGIN (RSA|OPENSSH|PRIVATE) KEY-----)" \
--exclude-dir=.git \
--exclude-dir=vendor \
--exclude-dir=node_modules \
--exclude=.env.example \
--exclude=.env.prod.example \
.
allow_failure: false
# Optional GitLab-native security scanners.
# These run only if your GitLab tier/project supports the templates.
include:
- template: Jobs/SAST.gitlab-ci.yml
- template: Jobs/Secret-Detection.gitlab-ci.yml
- template: Jobs/Dependency-Scanning.gitlab-ci.yml
+7 -1
View File
@@ -18,14 +18,20 @@
</include>
</source>
<php>
<ini name="memory_limit" value="512M"/>
<env name="APP_KEY" value="base64:2fl+Ktvkdp+Fuz8Qp/A78G7htTg43ZfXjJ9hH9jE8k="/>
<env name="JWT_SECRET" value="testing_jwt_secret_not_for_production"/>
<env name="APP_ENV" value="testing"/>
<env name="APP_MAINTENANCE_DRIVER" value="file"/>
<env name="BCRYPT_ROUNDS" value="4"/>
<env name="BROADCAST_CONNECTION" value="null"/>
<env name="CACHE_STORE" value="array"/>
<env name="DB_CONNECTION" value="sqlite"/>
<env name="DB_DATABASE" value=":memory:"/>
<env name="DB_DATABASE" value="database/database.sqlite"/>
<env name="MAIL_MAILER" value="array"/>
<env name="QUEUE_CONNECTION" value="sync"/>
<env name="SESSION_DRIVER" value="array"/>
+1 -1
View File
@@ -76,7 +76,7 @@ This document lists every API controller alphabetically with any inline route co
**File:** `app/Http/Controllers/Api/Auth/AuthController.php`
**Purpose:** JWT-backed authentication endpoints.
- `POST /api/v1/auth/login` (`login`) Validates `email` and `password`, looks up the user case-insensitively, uses `verify_stored_password`, and returns `{access_token, token_type, expires_in}` or `401`.
- `POST /api/v1/auth/login` (`login`) Validates `email` and `password`, looks up the user case-insensitively, uses `verify_stored_password`, and returns a top-level `{token, access_token, token_type, expires_in, user}` payload or `401`.
- `GET /api/v1/auth/me` (`me`) Requires a valid bearer token, returns the decoded `User` JSON via `JWTAuth::parseToken()->authenticate()`.
- `POST /api/v1/auth/logout` (`logout`) Invalidates the callers JWT token (best-effort) and returns `{message: "Logged out"}` even if the token was already invalid.
+4791 -1294
View File
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

BIN
View File
Binary file not shown.
+279
View File
@@ -0,0 +1,279 @@
<?php
namespace Tests\Concerns;
use App\Models\Role;
use App\Models\SchoolYear;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Illuminate\Testing\TestResponse;
/**
* Reusable deterministic fixtures for end-to-end API scenario tests.
*
* @see docs/alrahma_end_to_end_use_case_test_plan.md Part III
*/
trait SeedsE2ETestFixtures
{
use CreatesApiTestUsers;
protected const E2E_SCHOOL_YEAR = '2025-2026';
protected const E2E_PREV_SCHOOL_YEAR = '2024-2025';
protected const E2E_SEMESTER = 'Fall';
protected function seedE2EConfiguration(): void
{
// Pin calendar context so semester-aware services resolve to Fall 2025-2026.
$this->travelTo('2025-10-05 10:00:00');
$this->seedApiTestConfig(self::E2E_SCHOOL_YEAR, self::E2E_SEMESTER);
DB::table('configuration')->updateOrInsert(
['config_key' => 'fall_semester_start'],
['config_value' => '2025-09-01']
);
DB::table('configuration')->updateOrInsert(
['config_key' => 'spring_semester_start'],
['config_value' => '2026-01-15']
);
DB::table('configuration')->updateOrInsert(
['config_key' => 'last_school_day'],
['config_value' => '2026-06-15']
);
}
protected function seedClosedSchoolYear(): void
{
SchoolYear::query()->updateOrCreate(
['name' => self::E2E_PREV_SCHOOL_YEAR],
[
'start_date' => '2024-09-01',
'end_date' => '2025-06-30',
'status' => SchoolYear::STATUS_CLOSED,
'is_current' => false,
]
);
}
protected function seedClassSection(
int $classSectionId = 701,
string $name = '5A',
int $classId = 5
): int {
DB::table('classSection')->insert([
'class_id' => $classId,
'class_section_id' => $classSectionId,
'class_section_name' => $name,
'semester' => self::E2E_SEMESTER,
'school_year' => self::E2E_SCHOOL_YEAR,
]);
return $classSectionId;
}
protected function seedTeacherClassAssignment(int $teacherId, int $classSectionId, string $position = 'main'): void
{
DB::table('teacher_class')->insert([
'teacher_id' => $teacherId,
'class_section_id' => $classSectionId,
'position' => $position,
'school_year' => self::E2E_SCHOOL_YEAR,
'semester' => self::E2E_SEMESTER,
'created_at' => now(),
'updated_at' => now(),
]);
}
protected function seedStudentClassAssignment(int $studentId, int $classSectionId): void
{
DB::table('student_class')->insert([
'student_id' => $studentId,
'class_section_id' => $classSectionId,
'school_year' => self::E2E_SCHOOL_YEAR,
'semester' => self::E2E_SEMESTER,
'is_event_only' => 0,
'created_at' => now(),
'updated_at' => now(),
]);
}
/**
* @return array{teacher: User, class_section_id: int, student_id: int, parent_id: int}
*/
protected function seedTeacherClassWithStudent(?int $classSectionId = null): array
{
$this->seedE2EConfiguration();
$classSectionId ??= $this->seedClassSection(801, '3A', 3);
$teacher = $this->createApiUserWithRole('teacher', [
'firstname' => 'Tara',
'lastname' => 'Teacher',
'email' => 'e2e.teacher.' . uniqid('', true) . '@example.test',
]);
$this->seedTeacherClassAssignment($teacher->id, $classSectionId);
$parent = $this->createApiUserWithRole('parent', [
'firstname' => 'Parent',
'lastname' => 'Fixture',
'email' => 'e2e.parent.' . uniqid('', true) . '@example.test',
]);
$studentId = DB::table('students')->insertGetId([
'school_id' => 'S-E2E-' . random_int(1000, 9999),
'firstname' => 'Kai',
'lastname' => 'Student',
'dob' => '2014-09-01',
'age' => 11,
'gender' => 'Male',
'photo_consent' => 1,
'parent_id' => $parent->id,
'year_of_registration' => '2025',
'school_year' => self::E2E_SCHOOL_YEAR,
'semester' => self::E2E_SEMESTER,
'is_active' => 1,
]);
$this->seedStudentClassAssignment($studentId, $classSectionId);
return [
'teacher' => $teacher,
'class_section_id' => $classSectionId,
'student_id' => $studentId,
'parent_id' => $parent->id,
'parent' => $parent,
];
}
protected function createParentAccountViaApi(array $overrides = []): int
{
$parentRoleId = (int) Role::query()->where('name', 'parent')->value('id');
$unique = str_replace('.', '', uniqid('', true));
$payload = array_merge([
'firstname' => 'E2E',
'lastname' => 'Parent',
'gender' => 'Female',
'cellphone' => '5551234567',
'email' => "e2e.parent.$unique@example.test",
'address_street' => '42 Cedar Lane',
'city' => 'Brooklyn',
'state' => 'NY',
'zip' => '11201',
'accept_school_policy' => true,
'role_id' => $parentRoleId,
'password' => 'secret123',
'semester' => self::E2E_SEMESTER,
'school_year' => self::E2E_SCHOOL_YEAR,
'user_type' => 'primary',
'status' => 'Active',
'is_verified' => true,
], $overrides);
$response = $this->postJson('/api/v1/users', $payload);
$response->assertStatus(201)->assertJsonPath('ok', true);
return (int) $response->json('user_id');
}
protected function createStudentViaApi(int $parentId, array $overrides = [], bool $firstChild = true): int
{
$unique = str_replace('.', '', uniqid('', true));
$payload = array_merge([
'firstname' => 'E2E',
'lastname' => 'Student',
'dob' => '2015-05-10',
'age' => 10,
'gender' => 'Male',
'is_active' => true,
'registration_grade' => '5',
'is_new' => true,
'photo_consent' => true,
'parent_id' => $parentId,
'registration_date' => '2025-09-01',
'tuition_paid' => false,
'semester' => self::E2E_SEMESTER,
'year_of_registration' => 2025,
'school_year' => self::E2E_SCHOOL_YEAR,
], $overrides);
if ($firstChild) {
$payload = array_merge($payload, [
'name' => $overrides['name'] ?? 'E2E Parent',
'cellphone' => $overrides['cellphone'] ?? '5551234567',
'email' => $overrides['email'] ?? "e2e.contact.$unique@example.test",
'relation' => $overrides['relation'] ?? 'Mother',
]);
}
$response = $this->postJson('/api/v1/students', $payload);
$response->assertStatus(201)->assertJsonPath('ok', true);
return (int) $response->json('student.id');
}
protected function assignStudentToClassViaApi(int $studentId, int $classSectionId): TestResponse
{
return $this->postJson('/api/v1/students/assign-class', [
'student_id' => $studentId,
'class_section_id' => [$classSectionId],
]);
}
protected function postEnrollmentStatuses(array $studentStatusMap): TestResponse
{
return $this->post(
'/api/v1/administrator/enrollment-withdrawal/update-statuses?' .
http_build_query(['enrollment_status' => $studentStatusMap]),
[]
);
}
protected function seedInvoiceForParent(int $parentId, array $overrides = []): int
{
return DB::table('invoices')->insertGetId(array_merge([
'parent_id' => $parentId,
'invoice_number' => 'INV-E2E-' . random_int(1000, 9999),
'total_amount' => 500.00,
'balance' => 500.00,
'paid_amount' => 0,
'has_discount' => 0,
'issue_date' => '2025-09-01',
'due_date' => '2025-10-01',
'status' => 'unpaid',
'school_year' => self::E2E_SCHOOL_YEAR,
'semester' => self::E2E_SEMESTER,
'created_at' => now(),
'updated_at' => now(),
], $overrides));
}
protected function seedPriorYearUnpaidInvoice(int $parentId): int
{
return DB::table('invoices')->insertGetId([
'parent_id' => $parentId,
'invoice_number' => 'INV-PRIOR-' . random_int(1000, 9999),
'total_amount' => 200.00,
'balance' => 150.00,
'paid_amount' => 50.00,
'has_discount' => 0,
'issue_date' => '2024-09-01',
'due_date' => '2024-10-01',
'status' => 'unpaid',
'school_year' => self::E2E_PREV_SCHOOL_YEAR,
'semester' => self::E2E_SEMESTER,
'created_at' => now(),
'updated_at' => now(),
]);
}
protected function seedDiscountVoucher(): int
{
return DB::table('discount_vouchers')->insertGetId([
'code' => 'E2E10-' . random_int(100, 999),
'discount_type' => 'fixed',
'discount_value' => 50,
'max_uses' => 100,
'times_used' => 0,
'is_active' => 1,
]);
}
}
@@ -13,6 +13,54 @@ class ApiAuthenticationAndAuthorizationTest extends TestCase
use CreatesApiTestUsers;
use RefreshDatabase;
public function test_api_login_returns_token_and_user_object(): void
{
$user = $this->createApiUserWithRole('teacher', [
'firstname' => 'Login',
'lastname' => 'Tester',
'email' => 'api.login@example.test',
]);
$this->postJson('/api/v1/auth/login', [
'email' => strtoupper($user->email),
'password' => 'password',
])
->assertOk()
->assertJsonPath('status', true)
->assertJsonStructure([
'token',
'access_token',
'token_type',
'expires_in',
'user' => ['id', 'name', 'firstname', 'lastname', 'email', 'roles'],
])
->assertJsonPath('user.email', 'api.login@example.test');
}
public function test_session_login_also_returns_token_and_user_object_for_spa_clients(): void
{
$user = $this->createApiUserWithRole('teacher', [
'firstname' => 'Session',
'lastname' => 'Tester',
'email' => 'session.login@example.test',
]);
$this->postJson('/user/login', [
'email' => $user->email,
'password' => 'password',
])
->assertOk()
->assertJsonPath('status', true)
->assertJsonStructure([
'token',
'access_token',
'user' => ['id', 'name', 'firstname', 'lastname', 'email', 'roles'],
'next_url',
])
->assertJsonPath('user.email', 'session.login@example.test');
}
public function test_auth_me_requires_authentication(): void
{
$this->getJson('/api/v1/auth/me')->assertUnauthorized();
@@ -0,0 +1,70 @@
<?php
namespace Tests\Feature\Api\V1\Administrator;
use App\Services\Administrator\AdministratorDashboardService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Mockery;
use Tests\Concerns\CreatesApiTestUsers;
use Tests\TestCase;
class AdministratorDashboardControllerTest extends TestCase
{
use CreatesApiTestUsers;
use RefreshDatabase;
protected function tearDown(): void
{
Mockery::close();
parent::tearDown();
}
public function test_metrics_require_authentication(): void
{
$this->getJson('/api/v1/administrator/dashboard/metrics')
->assertStatus(401);
}
public function test_metrics_are_forbidden_for_non_admin_users(): void
{
$teacher = $this->createApiUserWithRole('teacher');
$this->actingAs($teacher, 'api')
->getJson('/api/v1/administrator/dashboard/metrics')
->assertStatus(403);
}
public function test_metrics_return_payload_for_admin(): void
{
$admin = $this->createApiUserWithRole('administrator');
$mock = Mockery::mock(AdministratorDashboardService::class);
$mock->shouldReceive('metrics')->once()->andReturn([
'students' => 42,
'teachers' => 5,
]);
$this->app->instance(AdministratorDashboardService::class, $mock);
$this->actingAs($admin, 'api')
->getJson('/api/v1/administrator/dashboard/metrics')
->assertOk()
->assertJson(['students' => 42, 'teachers' => 5]);
}
public function test_user_search_passes_query_to_service(): void
{
$admin = $this->createApiUserWithRole('administrator');
$mock = Mockery::mock(AdministratorDashboardService::class);
$mock->shouldReceive('userSearch')
->once()
->with('smith')
->andReturn(['results' => [['id' => 1, 'name' => 'Smith']]]);
$this->app->instance(AdministratorDashboardService::class, $mock);
$this->actingAs($admin, 'api')
->getJson('/api/v1/administrator/dashboard/user-search?query=smith')
->assertOk()
->assertJsonPath('results.0.name', 'Smith');
}
}
@@ -0,0 +1,89 @@
<?php
namespace Tests\Feature\Api\V1\Administrator;
use App\Services\Administrator\Trophy\TrophyReportService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Mockery;
use Tests\Concerns\CreatesApiTestUsers;
use Tests\TestCase;
class TrophyControllerTest extends TestCase
{
use CreatesApiTestUsers;
use RefreshDatabase;
protected function tearDown(): void
{
Mockery::close();
parent::tearDown();
}
public function test_index_requires_authentication(): void
{
$this->getJson('/api/v1/administrator/trophy')
->assertStatus(401);
}
public function test_index_is_forbidden_for_non_admin_users(): void
{
$teacher = $this->createApiUserWithRole('teacher');
$this->actingAs($teacher, 'api')
->getJson('/api/v1/administrator/trophy')
->assertStatus(403);
}
public function test_index_returns_projection_for_admin(): void
{
$admin = $this->createApiUserWithRole('administrator');
$mock = Mockery::mock(TrophyReportService::class);
$mock->shouldReceive('projection')->once()->andReturn(['rows' => []]);
$this->app->instance(TrophyReportService::class, $mock);
$this->actingAs($admin, 'api')
->getJson('/api/v1/administrator/trophy')
->assertOk()
->assertJsonPath('ok', true)
->assertJsonPath('data.rows', []);
}
public function test_winners_returns_data_for_admin(): void
{
$admin = $this->createApiUserWithRole('administrator');
$mock = Mockery::mock(TrophyReportService::class);
$mock->shouldReceive('winners')->once()->andReturn(['winners' => []]);
$this->app->instance(TrophyReportService::class, $mock);
$this->actingAs($admin, 'api')
->getJson('/api/v1/administrator/trophy/winners')
->assertOk()
->assertJsonPath('ok', true);
}
public function test_final_returns_data_for_admin(): void
{
$admin = $this->createApiUserWithRole('administrator');
$mock = Mockery::mock(TrophyReportService::class);
$mock->shouldReceive('final')->once()->andReturn(['final' => []]);
$this->app->instance(TrophyReportService::class, $mock);
$this->actingAs($admin, 'api')
->getJson('/api/v1/administrator/trophy/final')
->assertOk()
->assertJsonPath('ok', true);
}
public function test_index_validates_percentile_range(): void
{
$admin = $this->createApiUserWithRole('administrator');
$this->actingAs($admin, 'api')
->getJson('/api/v1/administrator/trophy?percentile=150')
->assertStatus(422)
->assertJsonValidationErrors(['percentile']);
}
}
@@ -0,0 +1,53 @@
<?php
namespace Tests\Feature\Api\V1\BadgeScan;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\Concerns\CreatesApiTestUsers;
use Tests\TestCase;
class BadgeScanControllerTest extends TestCase
{
use CreatesApiTestUsers;
use RefreshDatabase;
public function test_scan_is_public_but_validates_badge_value(): void
{
$this->postJson('/api/v1/badge_scan/scan', [])
->assertStatus(422)
->assertJsonPath('status', false);
}
public function test_scan_returns_404_for_unrecognized_badge(): void
{
$this->postJson('/api/v1/badge_scan/scan', ['badge_scan' => 'UNKNOWN-BADGE-123'])
->assertNotFound()
->assertJsonPath('status', false);
}
public function test_logs_require_authentication(): void
{
$this->getJson('/api/v1/badge_scan/logs')
->assertStatus(401);
}
public function test_logs_are_forbidden_for_non_staff_roles(): void
{
$parent = $this->createApiUserWithRole('parent');
$this->actingAs($parent, 'api')
->getJson('/api/v1/badge_scan/logs')
->assertStatus(403);
}
public function test_logs_return_data_for_staff(): void
{
$admin = $this->createApiUserWithRole('administrator');
$this->actingAs($admin, 'api')
->getJson('/api/v1/badge_scan/logs')
->assertOk()
->assertJsonPath('status', true)
->assertJsonStructure(['status', 'data' => ['logs', 'meta']]);
}
}
@@ -0,0 +1,118 @@
<?php
namespace Tests\Feature\Api\V1\Finance;
use App\Models\EventCharge;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\Concerns\CreatesApiTestUsers;
use Tests\TestCase;
class EventChargeControllerTest extends TestCase
{
use CreatesApiTestUsers;
use RefreshDatabase;
private function makeCharge(array $overrides = []): EventCharge
{
return EventCharge::query()->create(array_merge([
'event_name' => 'Field Trip',
'description' => 'Annual field trip charge',
'amount' => 25.00,
'charged' => 0,
'semester' => 'Fall',
'school_year' => '2025-2026',
], $overrides));
}
public function test_show_requires_authentication(): void
{
$charge = $this->makeCharge();
$this->getJson('/api/v1/finance/event-charges/' . $charge->id)
->assertStatus(401);
}
public function test_show_returns_event_charge(): void
{
$this->actingAsApiAdministrator();
$charge = $this->makeCharge();
$this->getJson('/api/v1/finance/event-charges/' . $charge->id)
->assertOk()
->assertJsonPath('ok', true)
->assertJsonPath('event_charge.id', $charge->id)
->assertJsonPath('event_charge.event_name', 'Field Trip');
}
public function test_show_returns_404_for_unknown_charge(): void
{
$this->actingAsApiAdministrator();
$this->getJson('/api/v1/finance/event-charges/999999')
->assertNotFound();
}
public function test_update_modifies_event_charge(): void
{
$this->actingAsApiAdministrator();
$charge = $this->makeCharge();
$this->putJson('/api/v1/finance/event-charges/' . $charge->id, [
'event_name' => 'Museum Visit',
'amount' => 40.50,
])
->assertOk()
->assertJsonPath('ok', true)
->assertJsonPath('event_charge.event_name', 'Museum Visit');
$this->assertDatabaseHas('event_charges', [
'id' => $charge->id,
'event_name' => 'Museum Visit',
]);
}
public function test_approve_marks_charge_as_charged(): void
{
$this->actingAsApiAdministrator();
$charge = $this->makeCharge(['charged' => 0]);
$this->postJson('/api/v1/finance/event-charges/' . $charge->id . '/approve')
->assertOk()
->assertJsonPath('ok', true);
$this->assertDatabaseHas('event_charges', [
'id' => $charge->id,
'charged' => 1,
]);
}
public function test_void_clears_charged_flag(): void
{
$this->actingAsApiAdministrator();
$charge = $this->makeCharge(['charged' => 1]);
$this->postJson('/api/v1/finance/event-charges/' . $charge->id . '/void')
->assertOk()
->assertJsonPath('ok', true);
$this->assertDatabaseHas('event_charges', [
'id' => $charge->id,
'charged' => 0,
]);
}
public function test_destroy_voids_the_charge(): void
{
$this->actingAsApiAdministrator();
$charge = $this->makeCharge(['charged' => 1]);
$this->deleteJson('/api/v1/finance/event-charges/' . $charge->id)
->assertOk()
->assertJsonPath('ok', true);
$this->assertDatabaseHas('event_charges', [
'id' => $charge->id,
'charged' => 0,
]);
}
}
@@ -0,0 +1,106 @@
<?php
namespace Tests\Feature\Api\V1\Parents;
use App\Models\AuthorizedUser;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\Concerns\CreatesApiTestUsers;
use Tests\TestCase;
class AuthorizedUsersControllerTest extends TestCase
{
use CreatesApiTestUsers;
use RefreshDatabase;
private function makeAuthorizedUser(int $parentId, array $overrides = []): AuthorizedUser
{
return AuthorizedUser::query()->create(array_merge([
'user_id' => $parentId,
'firstname' => 'Aunt',
'lastname' => 'Carer',
'email' => 'carer-' . uniqid() . '@example.test',
'phone_number' => '5551112222',
'relation_to_user' => 'Aunt',
'status' => 'pending',
], $overrides));
}
public function test_index_requires_authentication(): void
{
$this->getJson('/api/v1/parents/authorized-users')
->assertStatus(401);
}
public function test_index_is_forbidden_for_non_parent_users(): void
{
$teacher = $this->createApiUserWithRole('teacher');
$this->actingAs($teacher, 'api')
->getJson('/api/v1/parents/authorized-users')
->assertStatus(403);
}
public function test_index_returns_only_the_parents_own_authorized_users(): void
{
$parent = $this->createApiUserWithRole('parent');
$otherParent = $this->createApiUserWithRole('parent');
$own = $this->makeAuthorizedUser($parent->id);
$this->makeAuthorizedUser($otherParent->id);
$response = $this->actingAs($parent, 'api')
->getJson('/api/v1/parents/authorized-users')
->assertOk()
->assertJsonPath('status', true);
$this->assertCount(1, $response->json('data'));
$this->assertSame($own->id, $response->json('data.0.id'));
}
public function test_show_returns_404_for_unknown_authorized_user(): void
{
$parent = $this->createApiUserWithRole('parent');
$this->actingAs($parent, 'api')
->getJson('/api/v1/parents/authorized-users/999999')
->assertNotFound()
->assertJsonPath('status', false);
}
public function test_show_returns_404_for_another_parents_authorized_user(): void
{
$parent = $this->createApiUserWithRole('parent');
$otherParent = $this->createApiUserWithRole('parent');
$foreign = $this->makeAuthorizedUser($otherParent->id);
$this->actingAs($parent, 'api')
->getJson('/api/v1/parents/authorized-users/' . $foreign->id)
->assertNotFound();
}
public function test_destroy_deletes_the_parents_authorized_user(): void
{
$parent = $this->createApiUserWithRole('parent');
$record = $this->makeAuthorizedUser($parent->id);
$this->actingAs($parent, 'api')
->deleteJson('/api/v1/parents/authorized-users/' . $record->id)
->assertOk()
->assertJsonPath('status', true);
$this->assertDatabaseMissing('authorized_users', ['id' => $record->id]);
}
public function test_parent_cannot_delete_another_parents_authorized_user(): void
{
$parent = $this->createApiUserWithRole('parent');
$otherParent = $this->createApiUserWithRole('parent');
$foreign = $this->makeAuthorizedUser($otherParent->id);
$this->actingAs($parent, 'api')
->deleteJson('/api/v1/parents/authorized-users/' . $foreign->id)
->assertNotFound();
$this->assertDatabaseHas('authorized_users', ['id' => $foreign->id]);
}
}
@@ -0,0 +1,82 @@
<?php
namespace Tests\Feature\Api\V1\Public;
use App\Models\Competition;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class PublicWinnersControllerTest extends TestCase
{
use RefreshDatabase;
public function test_competition_index_is_public_and_returns_published_competitions(): void
{
Competition::query()->create([
'title' => 'Published Quiz Bowl',
'school_year' => '2025-2026',
'is_published' => 1,
'published_at' => now(),
]);
Competition::query()->create([
'title' => 'Hidden Draft',
'school_year' => '2025-2026',
'is_published' => 0,
]);
$response = $this->getJson('/api/winners/competitions');
$response->assertOk()
->assertJsonPath('status', true)
->assertJsonStructure(['status', 'data' => ['competitions']]);
$titles = array_column($response->json('data.competitions'), 'title');
$this->assertContains('Published Quiz Bowl', $titles);
$this->assertNotContains('Hidden Draft', $titles);
}
public function test_competition_index_returns_empty_collection_when_none_published(): void
{
$this->getJson('/api/winners/competitions')
->assertOk()
->assertJsonPath('status', true)
->assertJsonPath('data.competitions', []);
}
public function test_competition_show_returns_payload_for_published_competition(): void
{
$competition = Competition::query()->create([
'title' => 'Spelling Bee',
'school_year' => '2025-2026',
'is_published' => 1,
'published_at' => now(),
]);
$this->getJson('/api/winners/competitions/' . $competition->id)
->assertOk()
->assertJsonPath('status', true)
->assertJsonPath('data.competition.id', $competition->id)
->assertJsonStructure(['data' => ['competition', 'winners_by_class', 'section_map', 'question_counts']]);
}
public function test_competition_show_returns_404_for_unknown_competition(): void
{
$this->getJson('/api/winners/competitions/999999')
->assertNotFound()
->assertJsonPath('status', false);
}
public function test_competition_show_returns_404_for_unpublished_competition(): void
{
$competition = Competition::query()->create([
'title' => 'Unpublished',
'school_year' => '2025-2026',
'is_published' => 0,
]);
$this->getJson('/api/winners/competitions/' . $competition->id)
->assertNotFound()
->assertJsonPath('status', false);
}
}
@@ -0,0 +1,42 @@
<?php
namespace Tests\Feature\Api\V1;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\Concerns\CreatesApiTestUsers;
use Tests\TestCase;
class ScannerControllerTest extends TestCase
{
use CreatesApiTestUsers;
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
$this->seedApiTestConfig();
}
public function test_process_requires_a_badge_code(): void
{
$this->postJson('/api/v1/scanner/process', ['badgeCode' => ''])
->assertStatus(400)
->assertJsonPath('status', false)
->assertJsonPath('ok', false);
}
public function test_process_returns_404_for_unknown_badge(): void
{
$this->postJson('/api/v1/scanner/process', ['badgeCode' => 'NO-SUCH-BADGE'])
->assertNotFound()
->assertJsonPath('status', false)
->assertJsonPath('ok', false);
}
public function test_process_accepts_alternative_badge_field_names(): void
{
$this->postJson('/api/v1/scanner/process', ['barcode' => 'STILL-UNKNOWN'])
->assertNotFound()
->assertJsonPath('status', false);
}
}
@@ -0,0 +1,50 @@
<?php
namespace Tests\Feature\Api\V1\System;
use App\Models\Stats;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Laravel\Sanctum\Sanctum;
use Tests\TestCase;
class StatsControllerTest extends TestCase
{
use RefreshDatabase;
public function test_stats_require_authentication(): void
{
$this->getJson('/api/v1/stats')
->assertStatus(401);
}
public function test_stats_index_returns_rows_for_authenticated_user(): void
{
Stats::query()->create([
'students' => 12,
'teachers' => 3,
'admins' => 2,
'users' => 17,
]);
Sanctum::actingAs(User::factory()->create());
$response = $this->getJson('/api/v1/stats');
$response->assertOk()
->assertJsonPath('status', true)
->assertJsonStructure(['status', 'message', 'data']);
$this->assertSame(12, $response->json('data.0.students'));
}
public function test_stats_index_returns_empty_array_when_no_stats(): void
{
Sanctum::actingAs(User::factory()->create());
$this->getJson('/api/v1/stats')
->assertOk()
->assertJsonPath('status', true)
->assertJsonPath('data', []);
}
}
@@ -0,0 +1,67 @@
<?php
namespace Tests\Feature\Api\V1\Utilities;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
use Laravel\Sanctum\Sanctum;
use Tests\TestCase;
class ProofreadControllerTest extends TestCase
{
use RefreshDatabase;
public function test_proofread_requires_authentication(): void
{
$this->postJson('/api/proofread', ['text' => 'hello'])
->assertStatus(401);
}
public function test_proofread_rejects_empty_text(): void
{
Sanctum::actingAs(User::factory()->create());
$this->postJson('/api/proofread', ['text' => ''])
->assertStatus(422)
->assertJsonPath('ok', false);
}
public function test_proofread_rejects_text_that_is_too_long(): void
{
Sanctum::actingAs(User::factory()->create());
$this->postJson('/api/proofread', ['text' => str_repeat('a', 20001)])
->assertStatus(422)
->assertJsonPath('ok', false);
}
public function test_proofread_proxies_languagetool_and_returns_result(): void
{
Http::fake([
'api.languagetool.org/*' => Http::response(['matches' => []], 200),
]);
Sanctum::actingAs(User::factory()->create());
$this->postJson('/api/proofread', ['text' => 'This are a test.'])
->assertOk()
->assertJsonPath('ok', true)
->assertJsonStructure(['ok', 'result', 'csrfHash']);
Http::assertSent(fn ($request) => str_contains($request->url(), 'languagetool.org'));
}
public function test_proofread_returns_502_when_upstream_fails(): void
{
Http::fake([
'api.languagetool.org/*' => Http::response('error', 500),
]);
Sanctum::actingAs(User::factory()->create());
$this->postJson('/api/proofread', ['text' => 'Some text to check.'])
->assertStatus(502)
->assertJsonPath('ok', false);
}
}
@@ -0,0 +1,104 @@
<?php
namespace Tests\Feature\Api\V1\Workflows;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Event;
use Tests\Concerns\SeedsE2ETestFixtures;
use Tests\TestCase;
/**
* Scenario A: New Family Registration to Active Enrollment.
*
* @see docs/alrahma_end_to_end_use_case_test_plan.md Scenario A
*/
class ScenarioAFamilyEnrollmentTest extends TestCase
{
use RefreshDatabase;
use SeedsE2ETestFixtures;
public function test_admin_onboards_family_through_active_enrollment(): void
{
Event::fake();
$this->seedE2EConfiguration();
$admin = $this->actingAsApiAdministrator();
$classSectionId = $this->seedClassSection(701, '5A', 5);
$teacher = $this->createApiUserWithRole('teacher');
$this->seedTeacherClassAssignment($teacher->id, $classSectionId);
$parentId = $this->createParentAccountViaApi([
'firstname' => 'Khadija',
'lastname' => 'Saleh',
'email' => 'khadija.saleh@example.test',
]);
$studentId = $this->createStudentViaApi($parentId, [
'firstname' => 'Yusuf',
'lastname' => 'Saleh',
'name' => 'Khadija Saleh',
'email' => 'khadija.saleh@example.test',
]);
$this->assignStudentToClassViaApi($studentId, $classSectionId)
->assertOk()
->assertJsonPath('ok', true);
$this->postEnrollmentStatuses([$studentId => 'enrolled'])->assertOk();
$this->assertDatabaseHas('enrollments', [
'student_id' => $studentId,
'parent_id' => $parentId,
'enrollment_status' => 'enrolled',
]);
// Teacher sees student in roster.
$this->actingAs($teacher, 'api');
$this->getJson('/api/v1/teachers/classes?class_section_id=' . $classSectionId)
->assertOk()
->assertJsonPath('ok', true);
$studentIds = array_column($this->getJson('/api/v1/teachers/classes?class_section_id=' . $classSectionId)->json('students'), 'student_id');
$this->assertContains($studentId, $studentIds);
// Parent sees own profile.
$parent = User::query()->findOrFail($parentId);
$this->actingAs($parent, 'api');
$this->getJson('/api/v1/parents/profile')
->assertOk()
->assertJsonPath('ok', true);
// Admin can search the new user.
$this->actingAs($admin, 'api');
$this->getJson('/api/v1/administrator/dashboard/user-search?query=Saleh')
->assertOk();
}
public function test_parent_cannot_update_unrelated_student(): void
{
Event::fake();
$this->seedE2EConfiguration();
$this->actingAsApiAdministrator();
$parentA = $this->createApiUserWithRole('parent');
$parentB = $this->createApiUserWithRole('parent');
$studentB = $this->createStudentViaApi($parentB->id, [
'firstname' => 'Other',
'lastname' => 'Child',
]);
$this->actingAs($parentA, 'api');
$this->patchJson('/api/v1/parents/students/' . $studentB, [
'firstname' => 'Hacked',
'lastname' => 'Child',
'dob' => '2015-01-01',
'gender' => 'Male',
])->assertNotFound();
$this->assertDatabaseHas('students', [
'id' => $studentB,
'firstname' => 'Other',
]);
}
}
@@ -0,0 +1,107 @@
<?php
namespace Tests\Feature\Api\V1\Workflows;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Tests\Concerns\SeedsE2ETestFixtures;
use Tests\TestCase;
/**
* Scenario B: Normal Sunday School Day.
*
* @see docs/alrahma_end_to_end_use_case_test_plan.md Scenario B
*/
class ScenarioBSundaySchoolDayTest extends TestCase
{
use RefreshDatabase;
use SeedsE2ETestFixtures;
public function test_teacher_submits_attendance_and_parent_views_report(): void
{
$world = $this->seedTeacherClassWithStudent();
$teacher = $world['teacher'];
$classSectionId = $world['class_section_id'];
$studentId = $world['student_id'];
$parent = $world['parent'];
$this->actingAs($teacher, 'api');
$form = $this->getJson('/api/v1/attendance/teacher/form?class_section_id=' . $classSectionId)
->assertOk();
$date = (string) ($form->json('data.current_sunday') ?? '2025-10-05');
$submit = $this->postJson('/api/v1/attendance/teacher/submit', [
'class_section_id' => $classSectionId,
'date' => $date,
'attendance' => [
['student_id' => $studentId, 'status' => 'present'],
],
'teachers' => [
(string) $teacher->id => ['status' => 'present'],
],
]);
$submit->assertOk();
$this->assertDatabaseHas('attendance_data', [
'student_id' => $studentId,
'class_section_id' => $classSectionId,
'status' => 'present',
'school_year' => self::E2E_SCHOOL_YEAR,
]);
// Parent views attendance for their child.
$this->actingAs($parent, 'api');
$this->getJson('/api/v1/parents/attendance?school_year=' . self::E2E_SCHOOL_YEAR)
->assertOk()
->assertJsonPath('ok', true);
}
public function test_teacher_cannot_submit_attendance_for_unassigned_class(): void
{
$world = $this->seedTeacherClassWithStudent();
$otherSectionId = $this->seedClassSection(999, '9Z', 9);
$otherTeacher = $this->createApiUserWithRole('teacher');
$this->seedTeacherClassAssignment($otherTeacher->id, $otherSectionId);
$this->actingAs($world['teacher'], 'api');
$this->postJson('/api/v1/attendance/teacher/submit', [
'class_section_id' => $otherSectionId,
'date' => '2025-10-05',
'attendance' => [
['student_id' => $world['student_id'], 'status' => 'present'],
],
'teachers' => [
(string) $world['teacher']->id => ['status' => 'present'],
],
])->assertStatus(422);
$this->assertDatabaseMissing('attendance_data', [
'student_id' => $world['student_id'],
'class_section_id' => $otherSectionId,
]);
}
public function test_admin_views_daily_attendance_summary(): void
{
$world = $this->seedTeacherClassWithStudent();
$admin = $this->actingAsApiAdministrator();
DB::table('attendance_data')->insert([
'class_id' => 3,
'class_section_id' => $world['class_section_id'],
'student_id' => $world['student_id'],
'school_id' => '1',
'date' => '2025-10-05',
'status' => 'late',
'semester' => self::E2E_SEMESTER,
'school_year' => self::E2E_SCHOOL_YEAR,
'created_at' => now(),
'updated_at' => now(),
]);
$this->actingAs($admin, 'api');
$this->getJson('/api/v1/attendance/admin/daily?date=2025-10-05')
->assertOk();
}
}
@@ -0,0 +1,95 @@
<?php
namespace Tests\Feature\Api\V1\Workflows;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Tests\Concerns\SeedsE2ETestFixtures;
use Tests\TestCase;
/**
* Scenario C: Academic Term Completion.
*
* @see docs/alrahma_end_to_end_use_case_test_plan.md Scenario C
*/
class ScenarioCAcademicTermTest extends TestCase
{
use RefreshDatabase;
use SeedsE2ETestFixtures;
public function test_teacher_enters_scores_admin_locks_grading_and_report_card_is_generated(): void
{
$world = $this->seedTeacherClassWithStudent();
$teacher = $world['teacher'];
$classSectionId = $world['class_section_id'];
$studentId = $world['student_id'];
DB::table('homework')->insert([
'student_id' => $studentId,
'school_id' => 1,
'class_section_id' => $classSectionId,
'updated_by' => $teacher->id,
'homework_index' => 1,
'score' => 85,
'semester' => self::E2E_SEMESTER,
'school_year' => self::E2E_SCHOOL_YEAR,
'created_at' => now(),
'updated_at' => now(),
]);
DB::table('semester_scores')->insert([
'student_id' => $studentId,
'class_section_id' => $classSectionId,
'school_id' => 'E2E-SCORE-1',
'school_year' => self::E2E_SCHOOL_YEAR,
'semester' => self::E2E_SEMESTER,
'midterm_exam_score' => '85',
'participation_score' => '10',
'created_at' => now(),
'updated_at' => now(),
]);
$this->actingAs($teacher, 'api');
$this->getJson('/api/v1/scores/overview?class_section_id=' . $classSectionId . '&semester=' . self::E2E_SEMESTER . '&school_year=' . self::E2E_SCHOOL_YEAR)
->assertOk()
->assertJsonPath('ok', true);
$this->postJson('/api/v1/scores/lock', [
'class_section_id' => $classSectionId,
'semester' => self::E2E_SEMESTER,
'school_year' => self::E2E_SCHOOL_YEAR,
'missing_ok' => [],
])->assertOk()->assertJsonPath('ok', true);
$this->assertDatabaseHas('grading_locks', [
'class_section_id' => $classSectionId,
'semester' => self::E2E_SEMESTER,
'school_year' => self::E2E_SCHOOL_YEAR,
'is_locked' => 1,
]);
$this->getJson('/api/v1/reports/report-cards/students/' . $studentId . '?school_year=' . self::E2E_SCHOOL_YEAR . '&semester=' . self::E2E_SEMESTER)
->assertOk();
}
public function test_teacher_invalid_homework_score_is_rejected(): void
{
$world = $this->seedTeacherClassWithStudent();
$this->actingAs($world['teacher'], 'api');
$response = $this->postJson('/api/v1/scores/homework', [
'class_section_id' => $world['class_section_id'],
'semester' => self::E2E_SEMESTER,
'school_year' => self::E2E_SCHOOL_YEAR,
'scores' => [
$world['student_id'] => [
1 => 999,
],
],
]);
$this->assertGreaterThanOrEqual(400, $response->getStatusCode());
$this->assertNotSame(true, $response->json('ok'));
}
}
@@ -0,0 +1,92 @@
<?php
namespace Tests\Feature\Api\V1\Workflows;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Tests\Concerns\SeedsE2ETestFixtures;
use Tests\TestCase;
/**
* Scenario D: Family Billing Lifecycle.
*
* @see docs/alrahma_end_to_end_use_case_test_plan.md Scenario D
*/
class ScenarioDFamilyBillingTest extends TestCase
{
use RefreshDatabase;
use SeedsE2ETestFixtures;
public function test_admin_runs_invoice_discount_payment_and_reporting_workflow(): void
{
$world = $this->seedTeacherClassWithStudent();
$admin = $this->actingAsApiAdministrator();
$parentId = $world['parent_id'];
$studentId = $world['student_id'];
DB::table('enrollments')->insert([
'parent_id' => $parentId,
'student_id' => $studentId,
'school_year' => self::E2E_SCHOOL_YEAR,
'semester' => self::E2E_SEMESTER,
'enrollment_status' => 'payment pending',
'enrollment_date' => '2025-09-01',
]);
$invoiceId = $this->seedInvoiceForParent($parentId);
$voucherId = $this->seedDiscountVoucher();
$this->actingAs($admin, 'api');
$this->postJson('/api/v1/discounts/apply', [
'voucher_id' => $voucherId,
'parent_ids' => [$parentId],
'allow_additional' => true,
])->assertOk()->assertJsonPath('ok', true);
$chargeResponse = $this->postJson('/api/v1/finance/event-charges', [
'event_name' => 'Science Fair',
'description' => 'Materials fee',
'amount' => 15.00,
'parent_id' => $parentId,
'student_id' => $studentId,
'school_year' => self::E2E_SCHOOL_YEAR,
'semester' => self::E2E_SEMESTER,
]);
$chargeResponse->assertOk()->assertJsonPath('ok', true);
$chargeId = (int) DB::table('event_charges')
->where('parent_id', $parentId)
->where('student_id', $studentId)
->where('event_name', 'Science Fair')
->orderByDesc('id')
->value('id');
$this->assertGreaterThan(0, $chargeId);
$this->postJson('/api/v1/finance/event-charges/' . $chargeId . '/approve')
->assertOk()
->assertJsonPath('ok', true);
$this->postJson('/api/v1/finance/payments', [
'parent_id' => $parentId,
'invoice_id' => $invoiceId,
'total_amount' => 100,
'number_of_installments' => 1,
'payment_date' => '2025-10-01',
'payment_method' => 'cash',
'semester' => self::E2E_SEMESTER,
'school_year' => self::E2E_SCHOOL_YEAR,
])->assertStatus(201)->assertJsonPath('ok', true);
$this->assertDatabaseHas('payments', [
'parent_id' => $parentId,
'invoice_id' => $invoiceId,
]);
$this->getJson('/api/v1/finance/payments/parent/' . $parentId . '?school_year=' . self::E2E_SCHOOL_YEAR)
->assertOk()
->assertJsonPath('ok', true);
$this->getJson('/api/v1/finance/unpaid-parents')
->assertOk()
->assertJsonPath('ok', true);
}
}
@@ -0,0 +1,122 @@
<?php
namespace Tests\Feature\Api\V1\Workflows;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Tests\Concerns\SeedsE2ETestFixtures;
use Tests\TestCase;
/**
* Scenario E: Finance Edge Lifecycle (carryforward + installments + follow-ups).
*
* @see docs/alrahma_end_to_end_use_case_test_plan.md Scenario E
*/
class ScenarioEFinanceEdgeTest extends TestCase
{
use RefreshDatabase;
use SeedsE2ETestFixtures;
public function test_admin_carries_forward_balance_and_manages_installments(): void
{
$this->seedE2EConfiguration();
$this->seedClosedSchoolYear();
$admin = $this->actingAsApiAdministrator();
$parent = $this->createApiUserWithRole('parent');
$priorInvoiceId = $this->seedPriorYearUnpaidInvoice($parent->id);
$preview = $this->getJson('/api/v1/finance/carryforwards/preview?' . http_build_query([
'from_school_year' => self::E2E_PREV_SCHOOL_YEAR,
'to_school_year' => self::E2E_SCHOOL_YEAR,
'parent_id' => $parent->id,
]));
$preview->assertOk()->assertJsonPath('ok', true);
$this->assertNotEmpty($preview->json('preview.rows'));
$draft = $this->postJson('/api/v1/finance/carryforwards/draft', [
'from_school_year' => self::E2E_PREV_SCHOOL_YEAR,
'to_school_year' => self::E2E_SCHOOL_YEAR,
'parent_id' => $parent->id,
'reason' => 'E2E carryforward',
]);
$draft->assertOk()->assertJsonPath('ok', true);
$this->assertNotEmpty($draft->json('result.created'));
$carryforwardId = (int) $draft->json('result.created.0.id');
$this->postJson('/api/v1/finance/carryforwards/' . $carryforwardId . '/approve')
->assertOk()
->assertJsonPath('ok', true);
$posted = $this->postJson('/api/v1/finance/carryforwards/' . $carryforwardId . '/post')
->assertOk()
->assertJsonPath('ok', true);
$newInvoiceId = (int) ($posted->json('carryforward.posted_invoice_id') ?? 0);
$this->assertGreaterThan(0, $newInvoiceId);
$plan = $this->postJson('/api/v1/finance/invoices/' . $newInvoiceId . '/installment-plans', [
'number_of_installments' => 2,
'first_due_date' => '2025-09-15',
'interval_months' => 1,
])->assertCreated()->assertJsonPath('ok', true);
$planId = (int) $plan->json('plan.id');
$this->postJson('/api/v1/finance/installment-plans/' . $planId . '/activate')
->assertOk()
->assertJsonPath('ok', true);
$paymentId = DB::table('payments')->insertGetId([
'parent_id' => $parent->id,
'invoice_id' => $newInvoiceId,
'total_amount' => 75.00,
'paid_amount' => 75.00,
'balance' => 0,
'number_of_installments' => 1,
'payment_method' => 'cash',
'payment_date' => '2025-09-20',
'school_year' => self::E2E_SCHOOL_YEAR,
'semester' => self::E2E_SEMESTER,
'status' => 'Paid',
'created_at' => now(),
'updated_at' => now(),
]);
$this->postJson('/api/v1/finance/payments/' . $paymentId . '/allocate-installments', [])
->assertOk()
->assertJsonPath('ok', true);
$this->getJson('/api/v1/finance/installments/overdue')
->assertOk()
->assertJsonPath('ok', true);
$this->postJson('/api/v1/finance/parent-payment-followups/' . $parent->id . '/note', [
'invoice_id' => $newInvoiceId,
'school_year' => self::E2E_SCHOOL_YEAR,
'note' => 'Called parent about prior-year balance.',
'contact_method' => 'phone',
])->assertCreated()->assertJsonPath('ok', true);
$this->postJson('/api/v1/finance/parent-payment-followups/' . $parent->id . '/promise-to-pay', [
'invoice_id' => $newInvoiceId,
'promise_to_pay_date' => '2025-10-15',
'promise_to_pay_amount' => 50,
])->assertCreated()->assertJsonPath('ok', true);
$this->postJson('/api/v1/finance/parent-payment-followups/' . $parent->id . '/resolve', [
'invoice_id' => $newInvoiceId,
'note' => 'Balance plan agreed.',
])->assertCreated()->assertJsonPath('ok', true);
$this->assertDatabaseHas('finance_balance_carryforwards', [
'id' => $carryforwardId,
'parent_id' => $parent->id,
'status' => 'posted_to_new_year',
]);
$this->assertDatabaseHas('invoices', [
'id' => $priorInvoiceId,
'parent_id' => $parent->id,
'school_year' => self::E2E_PREV_SCHOOL_YEAR,
]);
}
}
@@ -0,0 +1,93 @@
<?php
namespace Tests\Feature\Api\V1\Workflows;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Mail;
use Tests\Concerns\SeedsE2ETestFixtures;
use Tests\TestCase;
/**
* Scenario F: Teacher Submission Follow-Up.
*
* @see docs/alrahma_end_to_end_use_case_test_plan.md Scenario F
*/
class ScenarioFTeacherSubmissionTest extends TestCase
{
use RefreshDatabase;
use SeedsE2ETestFixtures;
public function test_admin_detects_missing_submission_and_notifies_teacher(): void
{
Mail::fake();
$world = $this->seedTeacherClassWithStudent();
$admin = $this->actingAsApiAdministrator();
$teacher = $world['teacher'];
$classSectionId = $world['class_section_id'];
$this->actingAs($admin, 'api');
$report = $this->getJson('/api/v1/administrator/teacher-submissions');
$report->assertOk();
$this->assertNotEmpty($report->json('rows'));
$row = collect($report->json('rows'))
->firstWhere('class_section_id', $classSectionId);
$this->assertNotNull($row);
$this->assertNotEmpty($row['missing_items'] ?? []);
$notify = $this->postJson('/api/v1/administrator/teacher-submissions/notify', [
'notify' => [
$classSectionId => [
$teacher->id => 1,
],
],
'missing_items' => [
$classSectionId => [
$teacher->id => base64_encode(json_encode($row['missing_items'])),
],
],
]);
$notify->assertOk()->assertJsonPath('sent', 1);
$this->assertDatabaseHas('teacher_submission_notification_history', [
'teacher_id' => $teacher->id,
'class_section_id' => $classSectionId,
'admin_id' => $admin->id,
'notification_category' => 'teacher_submissions',
]);
// Teacher submits missing homework after reminder.
$this->actingAs($teacher, 'api');
$this->postJson('/api/v1/scores/homework', [
'class_section_id' => $classSectionId,
'semester' => self::E2E_SEMESTER,
'school_year' => self::E2E_SCHOOL_YEAR,
'scores' => [
$world['student_id'] => [
1 => 88,
],
],
])->assertOk()->assertJsonPath('ok', true);
$this->assertDatabaseHas('homework', [
'student_id' => $world['student_id'],
'class_section_id' => $classSectionId,
'homework_index' => 1,
'score' => 88,
]);
}
public function test_duplicate_notify_requires_valid_payload(): void
{
$admin = $this->actingAsApiAdministrator();
$this->actingAs($admin, 'api');
$this->postJson('/api/v1/administrator/teacher-submissions/notify', [
'missing_items' => [],
])->assertStatus(422)
->assertJsonValidationErrors(['notify']);
}
}
@@ -0,0 +1,75 @@
<?php
namespace Tests\Feature\Api\V1\Workflows;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Tests\Concerns\SeedsE2ETestFixtures;
use Tests\TestCase;
/**
* Scenario G: Print and Badge Workflow.
*
* @see docs/alrahma_end_to_end_use_case_test_plan.md Scenario G
*/
class ScenarioGPrintBadgeTest extends TestCase
{
use RefreshDatabase;
use SeedsE2ETestFixtures;
public function test_print_request_badge_log_and_scan_workflow(): void
{
$world = $this->seedTeacherClassWithStudent();
$teacher = $world['teacher'];
$classSectionId = $world['class_section_id'];
$admin = $this->actingAsApiAdministrator();
$this->actingAs($teacher, 'api');
$create = $this->postJson('/api/v1/print-requests/hand-copy', [
'class_id' => $classSectionId,
'num_copies' => 2,
'required_by' => '2026-09-13 11:00:00',
'pickup_method' => 'Self Pickup',
]);
$create->assertCreated();
$requestId = (int) $create->json('data.print_request.id');
$this->actingAs($admin, 'api');
foreach (['assigned', 'done', 'delivered'] as $status) {
$this->patchJson('/api/v1/print-requests/' . $requestId . '/status', [
'status' => $status,
])->assertOk();
}
$this->assertDatabaseHas('print_requests', [
'id' => $requestId,
'status' => 'delivered',
]);
$badgeTag = 'E2E-BADGE-' . random_int(10000, 99999);
DB::table('users')->where('id', $teacher->id)->update(['rfid_tag' => $badgeTag]);
$this->postJson('/api/v1/badges/log-print', [
'user_ids' => [$teacher->id],
'school_year' => self::E2E_SCHOOL_YEAR,
'roles' => [(string) $teacher->id => 'Teacher'],
'classes' => [(string) $teacher->id => '3A'],
])->assertOk()->assertJsonPath('ok', true);
$this->postJson('/api/v1/badge_scan/scan', [
'badge_scan' => $badgeTag,
'school_year' => self::E2E_SCHOOL_YEAR,
'semester' => self::E2E_SEMESTER,
])->assertOk();
$this->assertDatabaseHas('scan_log', [
'user_id' => $teacher->id,
'card_id' => $badgeTag,
]);
$this->actingAs($admin, 'api');
$this->getJson('/api/v1/badge_scan/logs')
->assertOk()
->assertJsonPath('status', true);
}
}
@@ -0,0 +1,74 @@
<?php
namespace Tests\Feature\Api\V1\Workflows;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Hash;
use Tests\Concerns\SeedsE2ETestFixtures;
use Tests\TestCase;
/**
* Scenario H: Security and Unauthorized Access.
*
* @see docs/alrahma_end_to_end_use_case_test_plan.md Scenario H
*/
class ScenarioHSecurityUnauthorizedAccessTest extends TestCase
{
use RefreshDatabase;
use SeedsE2ETestFixtures;
public function test_cross_role_and_anonymous_access_is_blocked(): void
{
$this->seedE2EConfiguration();
$world = $this->seedTeacherClassWithStudent();
$parentA = $this->createApiUserWithRole('parent');
$teacher = $world['teacher'];
$otherSectionId = $this->seedClassSection(902, '8B', 8);
$otherTeacher = $this->createApiUserWithRole('teacher');
$this->seedTeacherClassAssignment($otherTeacher->id, $otherSectionId);
// Parent cannot update another family's student.
$this->actingAs($parentA, 'api');
$this->patchJson('/api/v1/parents/students/' . $world['student_id'], [
'firstname' => 'Blocked',
'lastname' => 'Update',
'dob' => '2014-01-01',
'gender' => 'Male',
])->assertNotFound();
// Teacher cannot submit attendance for a class they are not assigned to.
$this->actingAs($teacher, 'api');
$this->postJson('/api/v1/attendance/teacher/submit', [
'class_section_id' => $otherSectionId,
'date' => '2025-10-05',
'attendance' => [
['student_id' => $world['student_id'], 'status' => 'present'],
],
'teachers' => [
(string) $teacher->id => ['status' => 'present'],
],
])->assertStatus(422);
// Teacher cannot access admin-only dashboard routes.
$this->getJson('/api/v1/administrator/dashboard/metrics')->assertForbidden();
$this->getJson('/api/v1/print-requests/admin')->assertForbidden();
// Suspended user cannot log in after valid credentials.
$suspended = $this->createApiUserWithRole('parent', [
'email' => 'suspended.user@example.test',
'password' => Hash::make('secret123'),
'is_suspended' => 1,
]);
$this->postJson('/api/v1/auth/login', [
'email' => $suspended->email,
'password' => 'secret123',
])->assertStatus(403);
// Anonymous requests are rejected before business logic.
$this->actingAsGuest('api');
$this->getJson('/api/v1/finance/invoices/management')->assertUnauthorized();
$this->getJson('/api/v1/administrator/dashboard/metrics')->assertUnauthorized();
$this->getJson('/api/v1/parents/profile')->assertUnauthorized();
}
}
@@ -0,0 +1,282 @@
<?php
namespace Tests\Feature\Api\V1\Workflows;
use App\Models\Role;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Event;
use Tests\Concerns\CreatesApiTestUsers;
use Tests\TestCase;
/**
* End-to-end "happy path" for onboarding a family:
* 1. An administrator signs in.
* 2. They create a parent account.
* 3. They register a student under that parent.
* 4. They enrol the student into a class section.
* 5. They move the student's enrollment status to "enrolled".
*
* Each step uses the real HTTP API so the controllers, form requests,
* services and persistence layers are all exercised together.
*/
class StudentEnrollmentWorkflowTest extends TestCase
{
use CreatesApiTestUsers;
use RefreshDatabase;
private const SCHOOL_YEAR = '2025-2026';
private const SEMESTER = 'Fall';
public function test_admin_creates_account_adds_student_and_enrolls_them(): void
{
Event::fake();
// 1. Administrator is authenticated (also seeds roles + active school year).
$admin = $this->actingAsApiAdministrator();
$parentRoleId = (int) Role::query()->where('name', 'parent')->value('id');
$this->assertGreaterThan(0, $parentRoleId);
// 2. Create a brand-new parent account.
$createParent = $this->postJson('/api/v1/users', [
'firstname' => 'Khadija',
'lastname' => 'Saleh',
'gender' => 'Female',
'cellphone' => '5551234567',
'email' => 'khadija.saleh@example.test',
'address_street' => '42 Cedar Lane',
'city' => 'Brooklyn',
'state' => 'NY',
'zip' => '11201',
'accept_school_policy' => true,
'role_id' => $parentRoleId,
'password' => 'secret123',
'semester' => self::SEMESTER,
'school_year' => self::SCHOOL_YEAR,
'user_type' => 'primary',
'status' => 'Active',
'is_verified' => true,
]);
$createParent->assertStatus(201)->assertJsonPath('ok', true);
$parentId = (int) $createParent->json('user_id');
$this->assertGreaterThan(0, $parentId);
$this->assertDatabaseHas('users', [
'id' => $parentId,
'email' => 'khadija.saleh@example.test',
]);
$this->assertDatabaseHas('user_roles', [
'user_id' => $parentId,
'role_id' => $parentRoleId,
]);
// 3. Register the parent's first student (triggers the first-child
// emergency-contact branch in StudentController@store).
$createStudent = $this->postJson('/api/v1/students', [
'firstname' => 'Yusuf',
'lastname' => 'Saleh',
'dob' => '2015-05-10',
'age' => 10,
'gender' => 'Male',
'is_active' => true,
'registration_grade' => '5',
'is_new' => true,
'photo_consent' => true,
'parent_id' => $parentId,
'registration_date' => '2025-09-01',
'tuition_paid' => false,
'semester' => self::SEMESTER,
'year_of_registration' => 2025,
'school_year' => self::SCHOOL_YEAR,
// first-child contact details:
'name' => 'Khadija Saleh',
'cellphone' => '5551234567',
'email' => 'khadija.saleh@example.test',
'relation' => 'Mother',
]);
$createStudent->assertStatus(201)->assertJsonPath('ok', true);
$studentId = (int) $createStudent->json('student.id');
$this->assertGreaterThan(0, $studentId);
$this->assertDatabaseHas('students', [
'id' => $studentId,
'parent_id' => $parentId,
'firstname' => 'Yusuf',
'lastname' => 'Saleh',
]);
$this->assertDatabaseHas('emergency_contacts', [
'parent_id' => $parentId,
'emergency_contact_name' => 'Khadija Saleh',
'relation' => 'Mother',
]);
// 4. Enrol the student into a class section.
$classSectionId = $this->seedClassSection();
$enroll = $this->postJson('/api/v1/students/assign-class', [
'student_id' => $studentId,
'class_section_id' => [$classSectionId],
]);
$enroll->assertOk()->assertJsonPath('ok', true);
$this->assertDatabaseHas('student_class', [
'student_id' => $studentId,
'class_section_id' => $classSectionId,
'school_year' => self::SCHOOL_YEAR,
]);
// The class roster endpoint reflects the assignment.
$classes = $this->getJson('/api/v1/students/' . $studentId . '/classes');
$classes->assertOk()->assertJsonPath('ok', true);
$assignedIds = array_column($classes->json('classes'), 'class_section_id');
$this->assertContains($classSectionId, $assignedIds);
// 5. Move the student to "enrolled" via the enrollment workflow.
$statusResponse = $this->post(
'/api/v1/administrator/enrollment-withdrawal/update-statuses?' .
http_build_query(['enrollment_status' => [$studentId => 'enrolled']]),
[]
);
$statusResponse->assertOk();
$this->assertDatabaseHas('enrollments', [
'student_id' => $studentId,
'parent_id' => $parentId,
'school_year' => self::SCHOOL_YEAR,
'semester' => self::SEMESTER,
'enrollment_status' => 'enrolled',
'admission_status' => 'accepted',
]);
}
public function test_admin_can_withdraw_an_enrolled_student(): void
{
Event::fake();
$this->actingAsApiAdministrator();
$parentRoleId = (int) Role::query()->where('name', 'parent')->value('id');
$parentId = (int) $this->postJson('/api/v1/users', [
'firstname' => 'Omar',
'lastname' => 'Hadid',
'gender' => 'Male',
'cellphone' => '5559876543',
'email' => 'omar.hadid@example.test',
'address_street' => '7 Maple Ct',
'city' => 'Queens',
'state' => 'NY',
'zip' => '11367',
'accept_school_policy' => true,
'role_id' => $parentRoleId,
'password' => 'secret123',
'semester' => self::SEMESTER,
'school_year' => self::SCHOOL_YEAR,
'user_type' => 'primary',
'status' => 'Active',
'is_verified' => true,
])->json('user_id');
$studentId = (int) $this->postJson('/api/v1/students', [
'firstname' => 'Layla',
'lastname' => 'Hadid',
'gender' => 'Female',
'is_active' => true,
'registration_grade' => '2',
'parent_id' => $parentId,
'semester' => self::SEMESTER,
'school_year' => self::SCHOOL_YEAR,
'name' => 'Omar Hadid',
'cellphone' => '5559876543',
'email' => 'omar.hadid@example.test',
'relation' => 'Father',
])->json('student.id');
// First enrol...
$this->post(
'/api/v1/administrator/enrollment-withdrawal/update-statuses?' .
http_build_query(['enrollment_status' => [$studentId => 'enrolled']]),
[]
)->assertOk();
// ...then withdraw.
$this->post(
'/api/v1/administrator/enrollment-withdrawal/update-statuses?' .
http_build_query(['enrollment_status' => [$studentId => 'withdrawn']]),
[]
)->assertOk();
$this->assertDatabaseHas('enrollments', [
'student_id' => $studentId,
'parent_id' => $parentId,
'school_year' => self::SCHOOL_YEAR,
'enrollment_status' => 'withdrawn',
]);
}
public function test_creating_a_user_requires_a_valid_role(): void
{
$this->actingAsApiAdministrator();
$this->postJson('/api/v1/users', [
'firstname' => 'No',
'lastname' => 'Role',
'cellphone' => '5550001111',
'email' => 'no.role@example.test',
'address_street' => '1 Test St',
'city' => 'Town',
'state' => 'NY',
'zip' => '10001',
'accept_school_policy' => true,
'role_id' => 999999,
'password' => 'secret123',
'semester' => self::SEMESTER,
])->assertStatus(422)
->assertJsonValidationErrors(['role_id']);
}
public function test_student_registration_requires_first_child_contact_details(): void
{
$this->actingAsApiAdministrator();
$parentRoleId = (int) Role::query()->where('name', 'parent')->value('id');
$parentId = (int) $this->postJson('/api/v1/users', [
'firstname' => 'First',
'lastname' => 'Parent',
'gender' => 'Female',
'cellphone' => '5552223333',
'email' => 'first.parent@example.test',
'address_street' => '9 Birch Rd',
'city' => 'Bronx',
'state' => 'NY',
'zip' => '10451',
'accept_school_policy' => true,
'role_id' => $parentRoleId,
'password' => 'secret123',
'semester' => self::SEMESTER,
'school_year' => self::SCHOOL_YEAR,
])->json('user_id');
// First student for a parent must include name/cellphone/email.
$this->postJson('/api/v1/students', [
'firstname' => 'Solo',
'lastname' => 'Child',
'parent_id' => $parentId,
'school_year' => self::SCHOOL_YEAR,
])->assertStatus(422)
->assertJsonValidationErrors(['name', 'cellphone', 'email']);
}
private function seedClassSection(int $classSectionId = 701, int $classId = 1): int
{
DB::table('classSection')->insert([
'class_id' => $classId,
'class_section_id' => $classSectionId,
'class_section_name' => '5A',
'semester' => self::SEMESTER,
'school_year' => self::SCHOOL_YEAR,
]);
return $classSectionId;
}
}
@@ -44,8 +44,12 @@ class ApiLoginSecurityServiceTest extends TestCase
$this->assertTrue($payload['status']);
$this->assertSame('jwt-token', $payload['token']);
$this->assertSame('jwt-token', $payload['access_token']);
$this->assertSame('bearer', $payload['token_type']);
$this->assertSame(99, $payload['user']['id']);
$this->assertSame('Amina Teacher', $payload['user']['name']);
$this->assertSame('Amina', $payload['user']['firstname']);
$this->assertSame('Teacher', $payload['user']['lastname']);
$this->assertSame(42, $payload['user']['class_section_id']);
$this->assertSame('Grade 5 - A', $payload['user']['class_section_name']);
$this->assertTrue($payload['user']['roles']->Teacher);
+3 -3
View File
@@ -13,9 +13,9 @@ class AuthSessionServiceTest extends TestCase
protected function setUp(): void
{
parent::setUp();
$urls = $this->createMock(ApplicationUrlService::class);
$urls->method('docsHomeUrl')->willReturn('http://example.org');
$this->svc = new AuthSessionService($urls);
// ApplicationUrlService is final (cannot be mocked); these tests only exercise
// relative-path sanitization and never call docsHomeUrl().
$this->svc = new AuthSessionService(new ApplicationUrlService());
}
public function test_sanitize_redirect_relative(): void
@@ -3,8 +3,7 @@
namespace Tests\Unit\Http\Controllers\Api;
use App\Http\Controllers\Api\Attendance\AttendanceCommentTemplateController;
use App\Http\Requests\AttendanceCommentTemplate\StoreAttendanceCommentTemplateRequest;
use App\Http\Requests\AttendanceCommentTemplate\UpdateAttendanceCommentTemplateRequest;
use App\Services\ApplicationUrlService;
use App\Services\AttendanceCommentTemplateService;
use Illuminate\Http\Request;
use Mockery;
@@ -12,6 +11,11 @@ use Tests\TestCase;
class AttendanceCommentTemplateControllerTest extends TestCase
{
private function makeController(AttendanceCommentTemplateService $service): AttendanceCommentTemplateController
{
return new AttendanceCommentTemplateController($service, new ApplicationUrlService());
}
protected function tearDown(): void
{
Mockery::close();
@@ -28,7 +32,7 @@ class AttendanceCommentTemplateControllerTest extends TestCase
['id' => 1, 'min_score' => 0, 'max_score' => 59, 'template_text' => 'Needs improvement', 'is_active' => true],
]);
$controller = new AttendanceCommentTemplateController($service);
$controller = $this->makeController($service);
$request = Request::create('/api/attendance-comment-templates', 'GET');
@@ -50,7 +54,7 @@ class AttendanceCommentTemplateControllerTest extends TestCase
->with(true)
->andReturn([]);
$controller = new AttendanceCommentTemplateController($service);
$controller = $this->makeController($service);
$request = Request::create('/api/attendance-comment-templates?active_only=1', 'GET');
@@ -75,7 +79,7 @@ class AttendanceCommentTemplateControllerTest extends TestCase
'is_active' => true,
]);
$controller = new AttendanceCommentTemplateController($service);
$controller = $this->makeController($service);
$response = $controller->show(5);
@@ -101,10 +105,9 @@ class AttendanceCommentTemplateControllerTest extends TestCase
->with($payload)
->andReturn(array_merge(['id' => 10], $payload));
$controller = new AttendanceCommentTemplateController($service);
$controller = $this->makeController($service);
$request = Mockery::mock(StoreAttendanceCommentTemplateRequest::class);
$request->shouldReceive('validated')->once()->andReturn($payload);
$request = Request::create('/api/attendance-comment-templates', 'POST', $payload);
$response = $controller->store($request);
@@ -135,10 +138,9 @@ class AttendanceCommentTemplateControllerTest extends TestCase
'is_active' => false,
]);
$controller = new AttendanceCommentTemplateController($service);
$controller = $this->makeController($service);
$request = Mockery::mock(UpdateAttendanceCommentTemplateRequest::class);
$request->shouldReceive('validated')->once()->andReturn($payload);
$request = Request::create('/api/attendance-comment-templates/7', 'PUT', $payload);
$response = $controller->update($request, 7);
@@ -155,7 +157,7 @@ class AttendanceCommentTemplateControllerTest extends TestCase
$service = Mockery::mock(AttendanceCommentTemplateService::class);
$service->shouldReceive('delete')->once()->with(12);
$controller = new AttendanceCommentTemplateController($service);
$controller = $this->makeController($service);
$response = $controller->destroy(12);
@@ -176,7 +178,7 @@ class AttendanceCommentTemplateControllerTest extends TestCase
['id' => 1, 'min_score' => 0, 'max_score' => 49, 'template_text' => 'Low', 'is_active' => true],
]);
$controller = new AttendanceCommentTemplateController($service);
$controller = $this->makeController($service);
$request = Request::create('/api/attendance-comment-templates/list-data', 'GET');
@@ -19,7 +19,7 @@ class NotificationUserListServiceTest extends TestCase
'title' => 'Unread',
'message' => 'Message 1',
'target_group' => 'parent',
'delivery_channels' => json_encode(['in_app']),
'delivery_channels' => 'in_app',
'scheduled_at' => now()->subHour(),
'created_at' => now(),
'updated_at' => now(),
@@ -29,7 +29,7 @@ class NotificationUserListServiceTest extends TestCase
'title' => 'Read',
'message' => 'Message 2',
'target_group' => 'parent',
'delivery_channels' => json_encode(['in_app']),
'delivery_channels' => 'in_app',
'scheduled_at' => now()->subHours(2),
'created_at' => now(),
'updated_at' => now(),
@@ -17,6 +17,9 @@ class ParentAttendanceReportServiceTest extends TestCase
public function test_form_data_returns_students(): void
{
// Pin calendar so upcoming Sundays exist within the 2025-2026 school year.
$this->travelTo('2025-10-05 10:00:00');
$this->seedConfig();
$parentId = $this->seedParent();
$this->seedStudent($parentId);
@@ -2,6 +2,7 @@
namespace Tests\Unit\Services\Payments;
use App\Services\ApplicationUrlService;
use App\Services\Payments\PaymentEnrollmentEventService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
@@ -11,9 +12,14 @@ class PaymentEnrollmentEventServiceTest extends TestCase
{
use RefreshDatabase;
private function makeService(): PaymentEnrollmentEventService
{
return new PaymentEnrollmentEventService(new ApplicationUrlService());
}
public function test_build_event_data_requires_parent(): void
{
$service = new PaymentEnrollmentEventService();
$service = $this->makeService();
$this->expectException(\RuntimeException::class);
@@ -43,7 +49,7 @@ class PaymentEnrollmentEventServiceTest extends TestCase
'school_year' => '2025-2026',
]);
$service = new PaymentEnrollmentEventService();
$service = $this->makeService();
[$parent, $students] = $service->buildEventData(10, [], '2025-2026', 'Fall', 'https://example.test/login');
$this->assertSame(10, $parent['user_id']);
@@ -23,7 +23,16 @@ class PaymentMissedCheckServiceTest extends TestCase
'id' => 1,
'firstname' => 'Parent',
'lastname' => 'Late',
'cellphone' => '5551112222',
'email' => 'late@example.com',
'address_street' => '123 Main',
'city' => 'City',
'state' => 'ST',
'zip' => '12345',
'accept_school_policy' => 1,
'password' => bcrypt('secret'),
'semester' => 'Spring',
'school_year' => '2024-2025',
'status' => 'Active',
]);
@@ -24,7 +24,7 @@ class PaymentNotificationDispatchServiceTest extends TestCase
$this->assertDatabaseHas('notifications', [
'title' => 'Payment Reminder',
'message' => 'Reminder body',
'target_group' => 'user',
'target_group' => 'everyone',
'status' => 'sent',
'school_year' => '2025-2026',
'semester' => 'Fall',
@@ -21,8 +21,38 @@ class PaymentTestNotificationServiceTest extends TestCase
]);
DB::table('users')->insert([
['id' => 10, 'firstname' => 'Primary', 'lastname' => 'Parent', 'email' => 'primary@example.com', 'status' => 'Active'],
['id' => 11, 'firstname' => 'Secondary', 'lastname' => 'Parent', 'email' => 'secondary@example.com', 'status' => 'Active'],
[
'id' => 10,
'firstname' => 'Primary',
'lastname' => 'Parent',
'cellphone' => '5551112222',
'email' => 'primary@example.com',
'address_street' => '123 Main',
'city' => 'City',
'state' => 'ST',
'zip' => '12345',
'accept_school_policy' => 1,
'password' => bcrypt('secret'),
'semester' => 'Spring',
'school_year' => '2024-2025',
'status' => 'Active',
],
[
'id' => 11,
'firstname' => 'Secondary',
'lastname' => 'Parent',
'cellphone' => '5553334444',
'email' => 'secondary@example.com',
'address_street' => '456 Oak',
'city' => 'City',
'state' => 'ST',
'zip' => '12345',
'accept_school_policy' => 1,
'password' => bcrypt('secret'),
'semester' => 'Spring',
'school_year' => '2024-2025',
'status' => 'Active',
],
]);
DB::table('families')->insert([
@@ -2,6 +2,7 @@
namespace Tests\Unit\Services\Payments;
use App\Services\ApplicationUrlService;
use App\Services\Payments\PaypalPaymentService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
@@ -11,6 +12,11 @@ class PaypalPaymentServiceTest extends TestCase
{
use RefreshDatabase;
private function makeService(): PaypalPaymentService
{
return new PaypalPaymentService(new ApplicationUrlService());
}
public function test_create_payment_falls_back_to_hosted_when_sdk_missing(): void
{
DB::table('payments')->insert([
@@ -27,7 +33,7 @@ class PaypalPaymentServiceTest extends TestCase
'status' => 'Pending',
]);
$service = new PaypalPaymentService();
$service = $this->makeService();
$result = $service->createPayment(1);
$this->assertSame('hosted', $result['mode']);
@@ -36,7 +42,7 @@ class PaypalPaymentServiceTest extends TestCase
public function test_execute_payment_throws_when_sdk_missing(): void
{
$service = new PaypalPaymentService();
$service = $this->makeService();
$this->expectException(\RuntimeException::class);