update tests
This commit is contained in:
@@ -12,6 +12,7 @@ use App\Services\Auth\AuthSessionService;
|
|||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use PHPOpenSourceSaver\JWTAuth\Facades\JWTAuth;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Session (cookie) endpoints for SPA — JSON alongside JWT on /api/login.
|
* Session (cookie) endpoints for SPA — JSON alongside JWT on /api/login.
|
||||||
@@ -141,12 +142,13 @@ class AuthSessionController extends Controller
|
|||||||
], 403);
|
], 403);
|
||||||
}
|
}
|
||||||
|
|
||||||
return response()->json([
|
$loginPayload = $this->security->buildLoginResponse($fresh, JWTAuth::fromUser($fresh));
|
||||||
'status' => true,
|
|
||||||
|
return response()->json(array_merge($loginPayload, [
|
||||||
'requires_role_selection' => ($dest['kind'] ?? '') === 'select_role',
|
'requires_role_selection' => ($dest['kind'] ?? '') === 'select_role',
|
||||||
'roles' => $dest['roles'] ?? null,
|
'roles' => $dest['roles'] ?? null,
|
||||||
'next_url' => $dest['redirect_url'] ?? $this->urls->docsHomeUrl(false),
|
'next_url' => $dest['redirect_url'] ?? $this->urls->docsHomeUrl(false),
|
||||||
]);
|
]));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Closes the current web session. */
|
/** Closes the current web session. */
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
use App\Models\BaseModel;
|
use App\Models\BaseModel;
|
||||||
|
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||||
|
|
||||||
class Notification extends BaseModel
|
class Notification extends BaseModel
|
||||||
@@ -28,12 +29,40 @@ class Notification extends BaseModel
|
|||||||
];
|
];
|
||||||
|
|
||||||
protected $casts = [
|
protected $casts = [
|
||||||
'scheduled_at' => 'datetime',
|
'scheduled_at' => 'datetime',
|
||||||
'expires_at' => 'datetime',
|
'expires_at' => 'datetime',
|
||||||
// If delivery_channels is JSON in DB:
|
|
||||||
'delivery_channels' => 'array',
|
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MySQL stores channels as SET('in_app','email','sms'); expose as array in PHP.
|
||||||
|
*/
|
||||||
|
protected function deliveryChannels(): Attribute
|
||||||
|
{
|
||||||
|
return Attribute::make(
|
||||||
|
get: static function (?string $value): array {
|
||||||
|
if ($value === null || $value === '') {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$trimmed = trim($value);
|
||||||
|
if (str_starts_with($trimmed, '[')) {
|
||||||
|
$decoded = json_decode($trimmed, true);
|
||||||
|
|
||||||
|
return is_array($decoded) ? array_values($decoded) : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_values(array_filter(array_map('trim', explode(',', $value))));
|
||||||
|
},
|
||||||
|
set: static function ($value): string {
|
||||||
|
if (is_array($value)) {
|
||||||
|
return implode(',', array_values(array_filter(array_map('strval', $value))));
|
||||||
|
}
|
||||||
|
|
||||||
|
return (string) $value;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Equivalent of legacy getActiveNotifications()
|
* Equivalent of legacy getActiveNotifications()
|
||||||
* Active = scheduled_at <= now AND (expires_at is null OR expires_at > now)
|
* 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';
|
protected $table = 'payment_transactions';
|
||||||
|
|
||||||
// ✅ legacy: useTimestamps = true (created_at/updated_at)
|
// Legacy table has no created_at/updated_at columns.
|
||||||
public $timestamps = true;
|
public $timestamps = false;
|
||||||
|
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
'transaction_id',
|
'transaction_id',
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ class StudentAllergy extends BaseModel
|
|||||||
/**
|
/**
|
||||||
* legacy: timestamps disabled
|
* legacy: timestamps disabled
|
||||||
*/
|
*/
|
||||||
public $timestamps = true;
|
public $timestamps = false;
|
||||||
|
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
'student_id',
|
'student_id',
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ class StudentMedicalCondition extends BaseModel
|
|||||||
/**
|
/**
|
||||||
* legacy: timestamps disabled
|
* legacy: timestamps disabled
|
||||||
*/
|
*/
|
||||||
public $timestamps = true;
|
public $timestamps = false;
|
||||||
|
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
'student_id',
|
'student_id',
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ use App\Models\TeacherClass;
|
|||||||
use Carbon\Carbon;
|
use Carbon\Carbon;
|
||||||
use Illuminate\Contracts\Auth\Authenticatable;
|
use Illuminate\Contracts\Auth\Authenticatable;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
use RuntimeException;
|
use RuntimeException;
|
||||||
|
|
||||||
class TeacherAttendanceSubmissionService
|
class TeacherAttendanceSubmissionService
|
||||||
@@ -171,6 +172,20 @@ class TeacherAttendanceSubmissionService
|
|||||||
$position = strtolower((string)($teacher['position'] ?? 'main'));
|
$position = strtolower((string)($teacher['position'] ?? 'main'));
|
||||||
$position = $position === 'ta' ? 'ta' : '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(
|
DB::table('staff_attendance')->updateOrInsert(
|
||||||
[
|
[
|
||||||
'user_id' => $teacherId,
|
'user_id' => $teacherId,
|
||||||
@@ -178,15 +193,7 @@ class TeacherAttendanceSubmissionService
|
|||||||
'semester' => $semester,
|
'semester' => $semester,
|
||||||
'school_year' => $schoolYear,
|
'school_year' => $schoolYear,
|
||||||
],
|
],
|
||||||
[
|
$staffPayload
|
||||||
'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(),
|
|
||||||
]
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -129,7 +129,7 @@ class ApiLoginSecurityService
|
|||||||
/**
|
/**
|
||||||
* Top-level JSON body for API login success responses.
|
* 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
|
public function buildLoginResponse(User $user, string $jwtToken): array
|
||||||
{
|
{
|
||||||
@@ -139,10 +139,19 @@ class ApiLoginSecurityService
|
|||||||
|
|
||||||
return [
|
return [
|
||||||
'status' => true,
|
'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,
|
'token' => $jwtToken,
|
||||||
|
'access_token' => $jwtToken,
|
||||||
|
'token_type' => 'bearer',
|
||||||
|
'expires_in' => (int) config('jwt.ttl') * 60,
|
||||||
'user' => [
|
'user' => [
|
||||||
'id' => (int) $user->id,
|
'id' => (int) $user->id,
|
||||||
'name' => $fullName,
|
'name' => $fullName,
|
||||||
|
'firstname' => $user->firstname,
|
||||||
|
'lastname' => $user->lastname,
|
||||||
|
'email' => $user->email,
|
||||||
'roles' => $this->rolesToObjectMap($roleNames),
|
'roles' => $this->rolesToObjectMap($roleNames),
|
||||||
'class_section_id' => $teacherContext['class_section_id'],
|
'class_section_id' => $teacherContext['class_section_id'],
|
||||||
'class_section_name' => $teacherContext['class_section_name'],
|
'class_section_name' => $teacherContext['class_section_name'],
|
||||||
|
|||||||
@@ -15,17 +15,17 @@ class ParentAttendanceReportCalendarService
|
|||||||
*/
|
*/
|
||||||
public function computeSundays(int $weeksBefore = 4, int $weeksAfter = 26): array
|
public function computeSundays(int $weeksBefore = 4, int $weeksAfter = 26): array
|
||||||
{
|
{
|
||||||
$today = new \DateTime('today');
|
$today = now()->startOfDay();
|
||||||
$dow = (int) $today->format('w');
|
$dow = (int) $today->format('w');
|
||||||
|
|
||||||
$thisSunday = clone $today;
|
$thisSunday = $today->copy();
|
||||||
if ($dow !== 0) {
|
if ($dow !== 0) {
|
||||||
$thisSunday->modify('last sunday');
|
$thisSunday->modify('last sunday');
|
||||||
}
|
}
|
||||||
|
|
||||||
$default = clone $today;
|
$default = $today->copy();
|
||||||
if ($dow !== 0) {
|
if ($dow !== 0) {
|
||||||
$default->modify('next sunday');
|
$default = $default->copy()->modify('next sunday');
|
||||||
}
|
}
|
||||||
|
|
||||||
$schoolYear = (string) ($this->configService->context()['school_year'] ?? '');
|
$schoolYear = (string) ($this->configService->context()['school_year'] ?? '');
|
||||||
@@ -33,13 +33,13 @@ class ParentAttendanceReportCalendarService
|
|||||||
|
|
||||||
$dates = [];
|
$dates = [];
|
||||||
for ($i = $weeksBefore; $i >= 1; $i--) {
|
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) {
|
while ($cursor <= $cap) {
|
||||||
$dates[] = clone $cursor;
|
$dates[] = $cursor->copy();
|
||||||
$cursor->modify('+1 week');
|
$cursor = $cursor->copy()->modify('+1 week');
|
||||||
}
|
}
|
||||||
|
|
||||||
$rangeStart = reset($dates);
|
$rangeStart = reset($dates);
|
||||||
@@ -108,7 +108,7 @@ class ParentAttendanceReportCalendarService
|
|||||||
|
|
||||||
public function firstSundayOfJune(string $schoolYear): \DateTime
|
public function firstSundayOfJune(string $schoolYear): \DateTime
|
||||||
{
|
{
|
||||||
$today = new \DateTime('today');
|
$today = now()->startOfDay();
|
||||||
$endYear = null;
|
$endYear = null;
|
||||||
if (preg_match('/^(\\d{4})\\D(\\d{4})$/', $schoolYear, $m)) {
|
if (preg_match('/^(\\d{4})\\D(\\d{4})$/', $schoolYear, $m)) {
|
||||||
$endYear = (int) $m[2];
|
$endYear = (int) $m[2];
|
||||||
@@ -118,10 +118,11 @@ class ParentAttendanceReportCalendarService
|
|||||||
$endYear = ((int) $today->format('n') > 6) ? ($y + 1) : $y;
|
$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) {
|
if ((int) $juneFirst->format('w') !== 0) {
|
||||||
$juneFirst->modify('next sunday');
|
$juneFirst = $juneFirst->copy()->modify('next sunday');
|
||||||
}
|
}
|
||||||
|
|
||||||
return $juneFirst;
|
return $juneFirst;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ class ParentRegistrationService
|
|||||||
$emergencyContacts,
|
$emergencyContacts,
|
||||||
$schoolYear,
|
$schoolYear,
|
||||||
$semester,
|
$semester,
|
||||||
|
$ageReference,
|
||||||
&$createdStudentIds,
|
&$createdStudentIds,
|
||||||
&$skippedStudents
|
&$skippedStudents
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ class PaymentNotificationDispatchService
|
|||||||
$notification = Notification::query()->create([
|
$notification = Notification::query()->create([
|
||||||
'title' => $title,
|
'title' => $title,
|
||||||
'message' => $message,
|
'message' => $message,
|
||||||
'target_group' => 'user',
|
'target_group' => 'everyone',
|
||||||
'delivery_channels' => $channels,
|
'delivery_channels' => $channels,
|
||||||
'priority' => 'normal',
|
'priority' => 'normal',
|
||||||
'status' => 'sent',
|
'status' => 'sent',
|
||||||
|
|||||||
@@ -67,7 +67,9 @@ class SemesterRangeService
|
|||||||
$lastDay = $this->configService->getLastSchoolDay();
|
$lastDay = $this->configService->getLastSchoolDay();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$target = new DateTimeImmutable($date ?: 'now');
|
$target = ($date !== null && $date !== '')
|
||||||
|
? new DateTimeImmutable($date)
|
||||||
|
: DateTimeImmutable::createFromInterface(now());
|
||||||
} catch (\Throwable) {
|
} catch (\Throwable) {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ class TeacherDashboardService
|
|||||||
->whereIn('tc.class_section_id', $classSectionIds)
|
->whereIn('tc.class_section_id', $classSectionIds)
|
||||||
->where('tc.school_year', $schoolYear)
|
->where('tc.school_year', $schoolYear)
|
||||||
->orderBy('tc.class_section_id')
|
->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')
|
->orderBy('u.firstname')
|
||||||
->get();
|
->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
|
# GitLab CI for Laravel
|
||||||
# Save this file as: .gitlab-ci.yml
|
# GitLab reads this file automatically when it is committed at the repository root as `.gitlab-ci.yml`.
|
||||||
|
|
||||||
default:
|
|
||||||
tags:
|
|
||||||
- runner1
|
|
||||||
|
|
||||||
workflow:
|
workflow:
|
||||||
rules:
|
rules:
|
||||||
@@ -15,150 +11,78 @@ workflow:
|
|||||||
when: always
|
when: always
|
||||||
- if: '$CI_COMMIT_TAG'
|
- if: '$CI_COMMIT_TAG'
|
||||||
when: always
|
when: always
|
||||||
- when: always
|
- when: never
|
||||||
|
|
||||||
stages:
|
stages:
|
||||||
- validate
|
|
||||||
- test
|
- test
|
||||||
- build
|
|
||||||
- security
|
|
||||||
|
|
||||||
variables:
|
variables:
|
||||||
APP_ENV: testing
|
APP_ENV: testing
|
||||||
APP_DEBUG: "false"
|
APP_DEBUG: "false"
|
||||||
LOG_CHANNEL: stderr
|
LOG_CHANNEL: stderr
|
||||||
CACHE_DRIVER: array
|
|
||||||
SESSION_DRIVER: array
|
|
||||||
QUEUE_CONNECTION: sync
|
|
||||||
DB_CONNECTION: sqlite
|
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
|
||||||
COMPOSER_CACHE_DIR: "$CI_PROJECT_DIR/.composer-cache"
|
COMPOSER_CACHE_DIR: "$CI_PROJECT_DIR/.composer-cache"
|
||||||
NPM_CONFIG_CACHE: "$CI_PROJECT_DIR/.npm-cache"
|
XDEBUG_MODE: coverage
|
||||||
|
|
||||||
cache:
|
cache:
|
||||||
key:
|
key:
|
||||||
files:
|
files:
|
||||||
- composer.lock
|
- composer.lock
|
||||||
- package-lock.json
|
|
||||||
paths:
|
paths:
|
||||||
- .composer-cache/
|
- .composer-cache/
|
||||||
- .npm-cache/
|
|
||||||
- vendor/
|
- vendor/
|
||||||
- node_modules/
|
|
||||||
|
|
||||||
.php_laravel_job:
|
phpunit:
|
||||||
image: php:8.3-cli
|
image: php:8.3-cli
|
||||||
|
stage: test
|
||||||
before_script:
|
before_script:
|
||||||
- apt-get update
|
- apt-get update
|
||||||
- apt-get install -y --no-install-recommends git unzip curl libzip-dev libpng-dev libonig-dev libxml2-dev libsqlite3-dev
|
- 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 pdo pdo_sqlite mbstring zip gd
|
- 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 -r "copy('https://getcomposer.org/installer', 'composer-setup.php');"
|
||||||
- php composer-setup.php --install-dir=/usr/local/bin --filename=composer
|
- php composer-setup.php --install-dir=/usr/local/bin --filename=composer
|
||||||
- rm composer-setup.php
|
- rm composer-setup.php
|
||||||
- composer install --prefer-dist --no-interaction --no-progress
|
- composer install --prefer-dist --no-interaction --no-progress
|
||||||
- cp .env.example .env || true
|
|
||||||
- |
|
- |
|
||||||
cat > .env <<'EOF'
|
cat > .env <<EOF
|
||||||
APP_NAME=school_api
|
APP_NAME=school_api
|
||||||
APP_ENV=testing
|
APP_ENV=testing
|
||||||
APP_KEY=
|
APP_KEY=
|
||||||
APP_DEBUG=false
|
APP_DEBUG=false
|
||||||
APP_URL=http://localhost
|
APP_URL=http://localhost
|
||||||
|
|
||||||
LOG_CHANNEL=stderr
|
LOG_CHANNEL=stderr
|
||||||
LOG_LEVEL=debug
|
LOG_LEVEL=debug
|
||||||
|
|
||||||
DB_CONNECTION=sqlite
|
DB_CONNECTION=sqlite
|
||||||
DB_DATABASE=${CI_PROJECT_DIR}/database/database.sqlite
|
DB_DATABASE=$CI_PROJECT_DIR/database/database.sqlite
|
||||||
|
|
||||||
CACHE_DRIVER=array
|
CACHE_DRIVER=array
|
||||||
|
CACHE_STORE=array
|
||||||
SESSION_DRIVER=array
|
SESSION_DRIVER=array
|
||||||
QUEUE_CONNECTION=sync
|
QUEUE_CONNECTION=sync
|
||||||
MAIL_MAILER=array
|
MAIL_MAILER=array
|
||||||
BROADCAST_CONNECTION=log
|
BROADCAST_CONNECTION=log
|
||||||
FILESYSTEM_DISK=local
|
FILESYSTEM_DISK=local
|
||||||
|
|
||||||
JWT_SECRET=ci_dummy_secret_not_for_production
|
JWT_SECRET=ci_dummy_secret_not_for_production
|
||||||
EOF
|
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
|
- touch database/database.sqlite
|
||||||
- php artisan key:generate --force
|
- php artisan key:generate --force
|
||||||
- php artisan config:clear
|
- 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 migrate --force
|
||||||
- php artisan test --testsuite=Feature --no-interaction
|
script:
|
||||||
- php artisan test --testsuite=Unit --no-interaction
|
- php artisan test --no-interaction --log-junit reports/phpunit.xml
|
||||||
artifacts:
|
artifacts:
|
||||||
when: always
|
when: always
|
||||||
expire_in: 7 days
|
expire_in: 7 days
|
||||||
|
reports:
|
||||||
|
junit: reports/phpunit.xml
|
||||||
paths:
|
paths:
|
||||||
|
- reports/
|
||||||
- storage/logs/
|
- 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>
|
</include>
|
||||||
</source>
|
</source>
|
||||||
<php>
|
<php>
|
||||||
|
<ini name="memory_limit" value="512M"/>
|
||||||
<env name="APP_KEY" value="base64:2fl+Ktvkdp+Fuz8Qp/A78G7htTg43ZfXjJ9hH9jE8k="/>
|
<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_ENV" value="testing"/>
|
||||||
<env name="APP_MAINTENANCE_DRIVER" value="file"/>
|
<env name="APP_MAINTENANCE_DRIVER" value="file"/>
|
||||||
<env name="BCRYPT_ROUNDS" value="4"/>
|
<env name="BCRYPT_ROUNDS" value="4"/>
|
||||||
<env name="BROADCAST_CONNECTION" value="null"/>
|
<env name="BROADCAST_CONNECTION" value="null"/>
|
||||||
<env name="CACHE_STORE" value="array"/>
|
<env name="CACHE_STORE" value="array"/>
|
||||||
<env name="DB_CONNECTION" value="sqlite"/>
|
<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="MAIL_MAILER" value="array"/>
|
||||||
<env name="QUEUE_CONNECTION" value="sync"/>
|
<env name="QUEUE_CONNECTION" value="sync"/>
|
||||||
<env name="SESSION_DRIVER" value="array"/>
|
<env name="SESSION_DRIVER" value="array"/>
|
||||||
@@ -33,4 +39,4 @@
|
|||||||
<env name="TELESCOPE_ENABLED" value="false"/>
|
<env name="TELESCOPE_ENABLED" value="false"/>
|
||||||
<env name="NIGHTWATCH_ENABLED" value="false"/>
|
<env name="NIGHTWATCH_ENABLED" value="false"/>
|
||||||
</php>
|
</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`
|
**File:** `app/Http/Controllers/Api/Auth/AuthController.php`
|
||||||
**Purpose:** JWT-backed authentication endpoints.
|
**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()`.
|
- `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.
|
- `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 CreatesApiTestUsers;
|
||||||
use RefreshDatabase;
|
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
|
public function test_auth_me_requires_authentication(): void
|
||||||
{
|
{
|
||||||
$this->getJson('/api/v1/auth/me')->assertUnauthorized();
|
$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->assertTrue($payload['status']);
|
||||||
$this->assertSame('jwt-token', $payload['token']);
|
$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(99, $payload['user']['id']);
|
||||||
$this->assertSame('Amina Teacher', $payload['user']['name']);
|
$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(42, $payload['user']['class_section_id']);
|
||||||
$this->assertSame('Grade 5 - A', $payload['user']['class_section_name']);
|
$this->assertSame('Grade 5 - A', $payload['user']['class_section_name']);
|
||||||
$this->assertTrue($payload['user']['roles']->Teacher);
|
$this->assertTrue($payload['user']['roles']->Teacher);
|
||||||
|
|||||||
@@ -13,9 +13,9 @@ class AuthSessionServiceTest extends TestCase
|
|||||||
protected function setUp(): void
|
protected function setUp(): void
|
||||||
{
|
{
|
||||||
parent::setUp();
|
parent::setUp();
|
||||||
$urls = $this->createMock(ApplicationUrlService::class);
|
// ApplicationUrlService is final (cannot be mocked); these tests only exercise
|
||||||
$urls->method('docsHomeUrl')->willReturn('http://example.org');
|
// relative-path sanitization and never call docsHomeUrl().
|
||||||
$this->svc = new AuthSessionService($urls);
|
$this->svc = new AuthSessionService(new ApplicationUrlService());
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_sanitize_redirect_relative(): void
|
public function test_sanitize_redirect_relative(): void
|
||||||
|
|||||||
@@ -3,8 +3,7 @@
|
|||||||
namespace Tests\Unit\Http\Controllers\Api;
|
namespace Tests\Unit\Http\Controllers\Api;
|
||||||
|
|
||||||
use App\Http\Controllers\Api\Attendance\AttendanceCommentTemplateController;
|
use App\Http\Controllers\Api\Attendance\AttendanceCommentTemplateController;
|
||||||
use App\Http\Requests\AttendanceCommentTemplate\StoreAttendanceCommentTemplateRequest;
|
use App\Services\ApplicationUrlService;
|
||||||
use App\Http\Requests\AttendanceCommentTemplate\UpdateAttendanceCommentTemplateRequest;
|
|
||||||
use App\Services\AttendanceCommentTemplateService;
|
use App\Services\AttendanceCommentTemplateService;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Mockery;
|
use Mockery;
|
||||||
@@ -12,6 +11,11 @@ use Tests\TestCase;
|
|||||||
|
|
||||||
class AttendanceCommentTemplateControllerTest extends TestCase
|
class AttendanceCommentTemplateControllerTest extends TestCase
|
||||||
{
|
{
|
||||||
|
private function makeController(AttendanceCommentTemplateService $service): AttendanceCommentTemplateController
|
||||||
|
{
|
||||||
|
return new AttendanceCommentTemplateController($service, new ApplicationUrlService());
|
||||||
|
}
|
||||||
|
|
||||||
protected function tearDown(): void
|
protected function tearDown(): void
|
||||||
{
|
{
|
||||||
Mockery::close();
|
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],
|
['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');
|
$request = Request::create('/api/attendance-comment-templates', 'GET');
|
||||||
|
|
||||||
@@ -50,7 +54,7 @@ class AttendanceCommentTemplateControllerTest extends TestCase
|
|||||||
->with(true)
|
->with(true)
|
||||||
->andReturn([]);
|
->andReturn([]);
|
||||||
|
|
||||||
$controller = new AttendanceCommentTemplateController($service);
|
$controller = $this->makeController($service);
|
||||||
|
|
||||||
$request = Request::create('/api/attendance-comment-templates?active_only=1', 'GET');
|
$request = Request::create('/api/attendance-comment-templates?active_only=1', 'GET');
|
||||||
|
|
||||||
@@ -75,7 +79,7 @@ class AttendanceCommentTemplateControllerTest extends TestCase
|
|||||||
'is_active' => true,
|
'is_active' => true,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$controller = new AttendanceCommentTemplateController($service);
|
$controller = $this->makeController($service);
|
||||||
|
|
||||||
$response = $controller->show(5);
|
$response = $controller->show(5);
|
||||||
|
|
||||||
@@ -101,10 +105,9 @@ class AttendanceCommentTemplateControllerTest extends TestCase
|
|||||||
->with($payload)
|
->with($payload)
|
||||||
->andReturn(array_merge(['id' => 10], $payload));
|
->andReturn(array_merge(['id' => 10], $payload));
|
||||||
|
|
||||||
$controller = new AttendanceCommentTemplateController($service);
|
$controller = $this->makeController($service);
|
||||||
|
|
||||||
$request = Mockery::mock(StoreAttendanceCommentTemplateRequest::class);
|
$request = Request::create('/api/attendance-comment-templates', 'POST', $payload);
|
||||||
$request->shouldReceive('validated')->once()->andReturn($payload);
|
|
||||||
|
|
||||||
$response = $controller->store($request);
|
$response = $controller->store($request);
|
||||||
|
|
||||||
@@ -135,10 +138,9 @@ class AttendanceCommentTemplateControllerTest extends TestCase
|
|||||||
'is_active' => false,
|
'is_active' => false,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$controller = new AttendanceCommentTemplateController($service);
|
$controller = $this->makeController($service);
|
||||||
|
|
||||||
$request = Mockery::mock(UpdateAttendanceCommentTemplateRequest::class);
|
$request = Request::create('/api/attendance-comment-templates/7', 'PUT', $payload);
|
||||||
$request->shouldReceive('validated')->once()->andReturn($payload);
|
|
||||||
|
|
||||||
$response = $controller->update($request, 7);
|
$response = $controller->update($request, 7);
|
||||||
|
|
||||||
@@ -155,7 +157,7 @@ class AttendanceCommentTemplateControllerTest extends TestCase
|
|||||||
$service = Mockery::mock(AttendanceCommentTemplateService::class);
|
$service = Mockery::mock(AttendanceCommentTemplateService::class);
|
||||||
$service->shouldReceive('delete')->once()->with(12);
|
$service->shouldReceive('delete')->once()->with(12);
|
||||||
|
|
||||||
$controller = new AttendanceCommentTemplateController($service);
|
$controller = $this->makeController($service);
|
||||||
|
|
||||||
$response = $controller->destroy(12);
|
$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],
|
['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');
|
$request = Request::create('/api/attendance-comment-templates/list-data', 'GET');
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ class NotificationUserListServiceTest extends TestCase
|
|||||||
'title' => 'Unread',
|
'title' => 'Unread',
|
||||||
'message' => 'Message 1',
|
'message' => 'Message 1',
|
||||||
'target_group' => 'parent',
|
'target_group' => 'parent',
|
||||||
'delivery_channels' => json_encode(['in_app']),
|
'delivery_channels' => 'in_app',
|
||||||
'scheduled_at' => now()->subHour(),
|
'scheduled_at' => now()->subHour(),
|
||||||
'created_at' => now(),
|
'created_at' => now(),
|
||||||
'updated_at' => now(),
|
'updated_at' => now(),
|
||||||
@@ -29,7 +29,7 @@ class NotificationUserListServiceTest extends TestCase
|
|||||||
'title' => 'Read',
|
'title' => 'Read',
|
||||||
'message' => 'Message 2',
|
'message' => 'Message 2',
|
||||||
'target_group' => 'parent',
|
'target_group' => 'parent',
|
||||||
'delivery_channels' => json_encode(['in_app']),
|
'delivery_channels' => 'in_app',
|
||||||
'scheduled_at' => now()->subHours(2),
|
'scheduled_at' => now()->subHours(2),
|
||||||
'created_at' => now(),
|
'created_at' => now(),
|
||||||
'updated_at' => now(),
|
'updated_at' => now(),
|
||||||
|
|||||||
@@ -17,6 +17,9 @@ class ParentAttendanceReportServiceTest extends TestCase
|
|||||||
|
|
||||||
public function test_form_data_returns_students(): void
|
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();
|
$this->seedConfig();
|
||||||
$parentId = $this->seedParent();
|
$parentId = $this->seedParent();
|
||||||
$this->seedStudent($parentId);
|
$this->seedStudent($parentId);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace Tests\Unit\Services\Payments;
|
namespace Tests\Unit\Services\Payments;
|
||||||
|
|
||||||
|
use App\Services\ApplicationUrlService;
|
||||||
use App\Services\Payments\PaymentEnrollmentEventService;
|
use App\Services\Payments\PaymentEnrollmentEventService;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
@@ -11,9 +12,14 @@ class PaymentEnrollmentEventServiceTest extends TestCase
|
|||||||
{
|
{
|
||||||
use RefreshDatabase;
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
private function makeService(): PaymentEnrollmentEventService
|
||||||
|
{
|
||||||
|
return new PaymentEnrollmentEventService(new ApplicationUrlService());
|
||||||
|
}
|
||||||
|
|
||||||
public function test_build_event_data_requires_parent(): void
|
public function test_build_event_data_requires_parent(): void
|
||||||
{
|
{
|
||||||
$service = new PaymentEnrollmentEventService();
|
$service = $this->makeService();
|
||||||
|
|
||||||
$this->expectException(\RuntimeException::class);
|
$this->expectException(\RuntimeException::class);
|
||||||
|
|
||||||
@@ -43,7 +49,7 @@ class PaymentEnrollmentEventServiceTest extends TestCase
|
|||||||
'school_year' => '2025-2026',
|
'school_year' => '2025-2026',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$service = new PaymentEnrollmentEventService();
|
$service = $this->makeService();
|
||||||
[$parent, $students] = $service->buildEventData(10, [], '2025-2026', 'Fall', 'https://example.test/login');
|
[$parent, $students] = $service->buildEventData(10, [], '2025-2026', 'Fall', 'https://example.test/login');
|
||||||
|
|
||||||
$this->assertSame(10, $parent['user_id']);
|
$this->assertSame(10, $parent['user_id']);
|
||||||
|
|||||||
@@ -23,7 +23,16 @@ class PaymentMissedCheckServiceTest extends TestCase
|
|||||||
'id' => 1,
|
'id' => 1,
|
||||||
'firstname' => 'Parent',
|
'firstname' => 'Parent',
|
||||||
'lastname' => 'Late',
|
'lastname' => 'Late',
|
||||||
|
'cellphone' => '5551112222',
|
||||||
'email' => 'late@example.com',
|
'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',
|
'status' => 'Active',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ class PaymentNotificationDispatchServiceTest extends TestCase
|
|||||||
$this->assertDatabaseHas('notifications', [
|
$this->assertDatabaseHas('notifications', [
|
||||||
'title' => 'Payment Reminder',
|
'title' => 'Payment Reminder',
|
||||||
'message' => 'Reminder body',
|
'message' => 'Reminder body',
|
||||||
'target_group' => 'user',
|
'target_group' => 'everyone',
|
||||||
'status' => 'sent',
|
'status' => 'sent',
|
||||||
'school_year' => '2025-2026',
|
'school_year' => '2025-2026',
|
||||||
'semester' => 'Fall',
|
'semester' => 'Fall',
|
||||||
|
|||||||
@@ -21,8 +21,38 @@ class PaymentTestNotificationServiceTest extends TestCase
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
DB::table('users')->insert([
|
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([
|
DB::table('families')->insert([
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace Tests\Unit\Services\Payments;
|
namespace Tests\Unit\Services\Payments;
|
||||||
|
|
||||||
|
use App\Services\ApplicationUrlService;
|
||||||
use App\Services\Payments\PaypalPaymentService;
|
use App\Services\Payments\PaypalPaymentService;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
@@ -11,6 +12,11 @@ class PaypalPaymentServiceTest extends TestCase
|
|||||||
{
|
{
|
||||||
use RefreshDatabase;
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
private function makeService(): PaypalPaymentService
|
||||||
|
{
|
||||||
|
return new PaypalPaymentService(new ApplicationUrlService());
|
||||||
|
}
|
||||||
|
|
||||||
public function test_create_payment_falls_back_to_hosted_when_sdk_missing(): void
|
public function test_create_payment_falls_back_to_hosted_when_sdk_missing(): void
|
||||||
{
|
{
|
||||||
DB::table('payments')->insert([
|
DB::table('payments')->insert([
|
||||||
@@ -27,7 +33,7 @@ class PaypalPaymentServiceTest extends TestCase
|
|||||||
'status' => 'Pending',
|
'status' => 'Pending',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$service = new PaypalPaymentService();
|
$service = $this->makeService();
|
||||||
$result = $service->createPayment(1);
|
$result = $service->createPayment(1);
|
||||||
|
|
||||||
$this->assertSame('hosted', $result['mode']);
|
$this->assertSame('hosted', $result['mode']);
|
||||||
@@ -36,7 +42,7 @@ class PaypalPaymentServiceTest extends TestCase
|
|||||||
|
|
||||||
public function test_execute_payment_throws_when_sdk_missing(): void
|
public function test_execute_payment_throws_when_sdk_missing(): void
|
||||||
{
|
{
|
||||||
$service = new PaypalPaymentService();
|
$service = $this->makeService();
|
||||||
|
|
||||||
$this->expectException(\RuntimeException::class);
|
$this->expectException(\RuntimeException::class);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user