update tests
This commit is contained in:
@@ -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. */
|
||||
|
||||
@@ -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
|
||||
@@ -28,12 +29,40 @@ class Notification extends BaseModel
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'scheduled_at' => 'datetime',
|
||||
'expires_at' => 'datetime',
|
||||
// If delivery_channels is JSON in DB:
|
||||
'delivery_channels' => 'array',
|
||||
'scheduled_at' => 'datetime',
|
||||
'expires_at' => 'datetime',
|
||||
];
|
||||
|
||||
/**
|
||||
* 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)
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -13,7 +13,7 @@ class StudentAllergy extends BaseModel
|
||||
/**
|
||||
* legacy: timestamps disabled
|
||||
*/
|
||||
public $timestamps = true;
|
||||
public $timestamps = false;
|
||||
|
||||
protected $fillable = [
|
||||
'student_id',
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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
Binary file not shown.
+24
-100
@@ -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
|
||||
|
||||
+8
-2
@@ -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"/>
|
||||
@@ -33,4 +39,4 @@
|
||||
<env name="TELESCOPE_ENABLED" value="false"/>
|
||||
<env name="NIGHTWATCH_ENABLED" value="false"/>
|
||||
</php>
|
||||
</phpunit>
|
||||
</phpunit>
|
||||
@@ -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 caller’s JWT token (best-effort) and returns `{message: "Logged out"}` even if the token was already invalid.
|
||||
|
||||
|
||||
+8895
-5398
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 |
Binary file not shown.
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user