fxi first test round

This commit is contained in:
root
2026-05-30 01:08:24 -04:00
parent f55213a3df
commit 6a40de4937
11 changed files with 1303 additions and 5 deletions
@@ -0,0 +1,457 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\Integration;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Carbon;
use Illuminate\Support\Str;
use Symfony\Component\HttpFoundation\Response;
final class SchoolWorkflowController extends Controller
{
public function storeSchool(Request $request): JsonResponse
{
$data = $request->validate([
'name' => ['required', 'string', 'max:255'],
'code' => ['nullable', 'string', 'max:64'],
'timezone' => ['nullable', 'string', 'max:64'],
'locale' => ['nullable', 'string', 'max:16'],
'currency' => ['nullable', 'string', 'size:3'],
'domain_profile' => ['nullable', 'string', 'max:64'],
]);
$id = (int) DB::table('schools')->insertGetId([
'name' => $data['name'],
'code' => $data['code'] ?? null,
'timezone' => $data['timezone'] ?? 'UTC',
'locale' => $data['locale'] ?? 'en',
'currency' => strtoupper($data['currency'] ?? 'USD'),
'domain_profile' => $data['domain_profile'] ?? 'standard_school',
'active' => true,
'created_at' => now(),
'updated_at' => now(),
]);
return $this->ok($this->schoolPayload($id), Response::HTTP_CREATED, 'School created.');
}
public function showSchool(int $school): JsonResponse
{
return $this->ok($this->schoolPayload($school));
}
public function storeStudent(Request $request, int $school): JsonResponse
{
$this->assertSchoolExists($school);
$data = $request->validate([
'first_name' => ['required', 'string', 'max:255'],
'last_name' => ['required', 'string', 'max:255'],
'student_number' => ['nullable', 'string', 'max:128'],
'school_id_number' => ['nullable', 'string', 'max:128'],
'grade_level' => ['nullable', 'string', 'max:64'],
'status' => ['nullable', 'string', 'max:64'],
]);
$studentNumber = $data['student_number'] ?? $data['school_id_number'] ?? null;
$id = (int) DB::table('students')->insertGetId([
'school_id' => $school,
'school_id_number' => $studentNumber,
'student_identifier' => $studentNumber,
'first_name' => $data['first_name'],
'last_name' => $data['last_name'],
'status' => $data['status'] ?? 'active',
'profile_metadata' => json_encode(['grade_level' => $data['grade_level'] ?? null], JSON_THROW_ON_ERROR),
'metadata' => json_encode(['grade_level' => $data['grade_level'] ?? null], JSON_THROW_ON_ERROR),
'created_at' => now(),
'updated_at' => now(),
]);
return $this->ok($this->studentPayload($school, $id), Response::HTTP_CREATED, 'Student created.');
}
public function showStudent(int $school, int $student): JsonResponse
{
return $this->ok($this->studentPayload($school, $student));
}
public function storeAttendanceScan(Request $request, int $school): JsonResponse
{
$this->assertSchoolExists($school);
$data = $request->validate([
'student_number' => ['required_without:student_id', 'string', 'max:128'],
'student_id' => ['required_without:student_number', 'integer'],
'scanned_at' => ['nullable', 'date'],
'source' => ['nullable', 'string', 'max:64'],
'station_id' => ['nullable', 'string', 'max:128'],
]);
$student = $this->findStudentByScanInput($school, $data);
$scannedAt = isset($data['scanned_at']) ? Carbon::parse($data['scanned_at']) : now();
$sessionId = $this->resolveDailySession($school, $scannedAt);
$existing = DB::table('attendance_records')
->where('school_id', $school)
->where('attendance_session_id', $sessionId)
->where('subject_type', 'student')
->where('subject_id', $student->id)
->first();
if ($existing) {
DB::table('attendance_records')->where('id', $existing->id)->update([
'last_seen_at' => $scannedAt,
'updated_at' => now(),
]);
$recordId = (int) $existing->id;
$statusResult = 'duplicate';
} else {
$recordId = (int) DB::table('attendance_records')->insertGetId([
'school_id' => $school,
'attendance_session_id' => $sessionId,
'attendance_date' => $scannedAt->toDateString(),
'subject_type' => 'student',
'subject_id' => $student->id,
'status' => 'present',
'source' => $data['source'] ?? 'scan',
'first_seen_at' => $scannedAt,
'last_seen_at' => $scannedAt,
'metadata' => json_encode(['student_number' => $student->school_id_number], JSON_THROW_ON_ERROR),
'created_at' => now(),
'updated_at' => now(),
]);
$statusResult = 'recorded';
}
$scanId = (int) DB::table('attendance_scan_logs')->insertGetId([
'school_id' => $school,
'attendance_record_id' => $recordId,
'subject_type' => 'student',
'subject_id' => $student->id,
'station_id' => $data['station_id'] ?? null,
'scan_action' => 'check_in',
'scan_source' => $data['source'] ?? 'scan',
'scanned_at' => $scannedAt,
'status_result' => $statusResult,
'raw_payload_json' => json_encode($data, JSON_THROW_ON_ERROR),
'created_at' => now(),
'updated_at' => now(),
]);
return $this->ok([
'scan_id' => $scanId,
'attendance_record_id' => $recordId,
'student_id' => (int) $student->id,
'status' => $statusResult,
], $statusResult === 'recorded' ? Response::HTTP_CREATED : Response::HTTP_OK, 'Attendance scan processed.');
}
public function previewCommunication(Request $request, int $school): JsonResponse
{
$this->assertSchoolExists($school);
$data = $request->validate([
'template_key' => ['required', 'string', 'max:128'],
'student_id' => ['required', 'integer'],
'channel' => ['nullable', 'string', 'max:32'],
]);
$student = $this->findStudent($school, (int) $data['student_id']);
$template = $this->findTemplate($school, $data['template_key'], $data['channel'] ?? null);
return $this->ok([
'template_key' => $template->template_key,
'subject' => $this->renderTemplate($template->subject, $student),
'body' => $this->renderTemplate($template->body, $student),
'student_id' => (int) $student->id,
]);
}
public function sendCommunication(Request $request, int $school): JsonResponse
{
$this->assertSchoolExists($school);
$data = $request->validate([
'template_key' => ['required', 'string', 'max:128'],
'student_id' => ['required', 'integer'],
'channel' => ['required', 'string', 'max:32'],
]);
$student = $this->findStudent($school, (int) $data['student_id']);
$template = $this->findTemplate($school, $data['template_key'], $data['channel']);
$messageId = (int) DB::table('communication_messages')->insertGetId([
'school_id' => $school,
'actor_user_id' => null,
'channel' => $data['channel'],
'message_category' => $template->message_category ?? 'general',
'priority' => 'normal',
'subject' => $this->renderTemplate($template->subject, $student),
'body_snapshot' => $this->renderTemplate($template->body, $student),
'template_id' => $template->id,
'template_version' => $template->version ?? '1',
'status' => 'queued',
'created_at' => now(),
'updated_at' => now(),
]);
$recipientId = (int) DB::table('communication_message_recipients')->insertGetId([
'message_id' => $messageId,
'school_id' => $school,
'recipient_type' => 'student',
'recipient_id' => $student->id,
'channel' => $data['channel'],
'status' => 'queued',
'created_at' => now(),
'updated_at' => now(),
]);
return $this->ok([
'message_id' => $messageId,
'recipient_id' => $recipientId,
'status' => 'queued',
], Response::HTTP_ACCEPTED, 'Communication queued.');
}
public function uploadPaymentFile(Request $request, int $school): JsonResponse
{
$this->assertSchoolExists($school);
$request->validate(['file' => ['required', 'file']]);
$file = $request->file('file');
$rows = array_map('str_getcsv', file($file->getRealPath()) ?: []);
$headers = array_map(static fn ($value): string => trim((string) $value), $rows[0] ?? []);
$required = ['student_number', 'amount', 'paid_at', 'reference'];
if (array_diff($required, $headers) !== []) {
return $this->fail('Invalid payment file.', Response::HTTP_UNPROCESSABLE_ENTITY, [
'file' => ['CSV must include student_number, amount, paid_at, and reference columns.'],
]);
}
$imported = [];
try {
return DB::transaction(function () use ($school, $headers, $rows, &$imported): JsonResponse {
foreach (array_slice($rows, 1) as $row) {
if ($row === [null] || $row === false || count(array_filter($row, static fn ($value) => $value !== null && $value !== '')) === 0) {
continue;
}
$record = array_combine($headers, array_pad($row, count($headers), null));
if (! $record || empty($record['student_number']) || ! is_numeric($record['amount']) || empty($record['paid_at']) || empty($record['reference'])) {
throw new \InvalidArgumentException('Invalid payment row.');
}
$student = DB::table('students')
->where('school_id', $school)
->where('school_id_number', $record['student_number'])
->first();
if (! $student) {
throw new \InvalidArgumentException('Payment row references an unknown student.');
}
$paymentId = (int) DB::table('payments')->insertGetId([
'school_id' => $school,
'student_id' => $student->id,
'amount_minor' => (int) round(((float) $record['amount']) * 100),
'currency' => 'USD',
'payment_method' => 'file_import',
'payment_date' => $record['paid_at'],
'reference_number' => $record['reference'],
'status' => 'recorded',
'metadata' => json_encode(['source' => 'payment_file'], JSON_THROW_ON_ERROR),
'created_at' => now(),
'updated_at' => now(),
]);
$imported[] = $paymentId;
}
return $this->ok([
'imported_count' => count($imported),
'payment_ids' => $imported,
], Response::HTTP_CREATED, 'Payment file imported.');
});
} catch (\InvalidArgumentException $exception) {
return $this->fail('Invalid payment file.', Response::HTTP_UNPROCESSABLE_ENTITY, [
'file' => [$exception->getMessage()],
]);
}
}
public function dailyReport(Request $request, int $school): JsonResponse
{
$this->assertSchoolExists($school);
$date = (string) $request->query('date', now()->toDateString());
return $this->ok([
'date' => $date,
'attendance' => [
'present_count' => DB::table('attendance_records')
->where('school_id', $school)
->where('attendance_date', $date)
->where('status', 'present')
->count(),
],
'finance' => [
'total_payments' => (int) DB::table('payments')
->where('school_id', $school)
->where('payment_date', $date)
->where('status', '!=', 'reversed')
->sum('amount_minor'),
],
]);
}
private function schoolPayload(int $school): array
{
$row = DB::table('schools')->where('id', $school)->first();
abort_if(! $row, Response::HTTP_NOT_FOUND, 'School not found.');
return [
'id' => (int) $row->id,
'name' => $row->name,
'code' => $row->code ?? null,
'timezone' => $row->timezone,
'locale' => $row->locale,
'currency' => $row->currency,
];
}
private function studentPayload(int $school, int $student): array
{
$row = $this->findStudent($school, $student);
$metadata = json_decode((string) ($row->profile_metadata ?? $row->metadata ?? '{}'), true) ?: [];
return [
'id' => (int) $row->id,
'school_id' => (int) $row->school_id,
'first_name' => $row->first_name,
'last_name' => $row->last_name,
'student_number' => $row->school_id_number ?? $row->student_identifier,
'grade_level' => $metadata['grade_level'] ?? null,
'status' => $row->status,
];
}
private function assertSchoolExists(int $school): void
{
abort_if(! DB::table('schools')->where('id', $school)->exists(), Response::HTTP_NOT_FOUND, 'School not found.');
}
private function findStudent(int $school, int $student): object
{
$row = DB::table('students')->where('school_id', $school)->where('id', $student)->first();
abort_if(! $row, Response::HTTP_NOT_FOUND, 'Student not found.');
return $row;
}
private function findStudentByScanInput(int $school, array $data): object
{
$query = DB::table('students')->where('school_id', $school);
if (isset($data['student_id'])) {
$query->where('id', $data['student_id']);
} else {
$query->where('school_id_number', $data['student_number']);
}
$row = $query->first();
abort_if(! $row, Response::HTTP_NOT_FOUND, 'Student not found.');
return $row;
}
private function resolveDailySession(int $school, \DateTimeInterface $scannedAt): int
{
$date = $scannedAt->format('Y-m-d');
$existing = DB::table('attendance_sessions')
->where('school_id', $school)
->whereDate('starts_at', $date)
->where('session_type', 'daily')
->first();
if ($existing) {
return (int) $existing->id;
}
return (int) DB::table('attendance_sessions')->insertGetId([
'school_id' => $school,
'name' => 'Daily attendance '.$date,
'session_type' => 'daily',
'starts_at' => $date.' 00:00:00',
'ends_at' => $date.' 23:59:59',
'timezone' => 'UTC',
'status' => 'open',
'created_at' => now(),
'updated_at' => now(),
]);
}
private function findTemplate(int $school, string $templateKey, ?string $channel): object
{
$query = DB::table('communication_templates')
->where(function ($query) use ($school): void {
$query->where('school_id', $school)->orWhereNull('school_id');
})
->where('template_key', $templateKey)
->where('status', '!=', 'archived')
->orderByDesc('school_id')
->orderByDesc('version');
if ($channel !== null) {
$query->where('channel', $channel);
}
$row = $query->first();
abort_if(! $row, Response::HTTP_NOT_FOUND, 'Communication template not found.');
return $row;
}
private function renderTemplate(?string $template, object $student): ?string
{
if ($template === null) {
return null;
}
return Str::of($template)
->replace('{{ student.first_name }}', (string) $student->first_name)
->replace('{{student.first_name}}', (string) $student->first_name)
->replace('{{ student.last_name }}', (string) $student->last_name)
->replace('{{student.last_name}}', (string) $student->last_name)
->toString();
}
private function ok(array $data, int $status = Response::HTTP_OK, ?string $message = null): JsonResponse
{
return response()->json([
'success' => true,
'data' => $data,
'message' => $message,
'errors' => null,
], $status);
}
private function fail(string $message, int $status, array $errors): JsonResponse
{
return response()->json([
'success' => false,
'data' => null,
'message' => $message,
'errors' => $errors,
], $status);
}
}
@@ -0,0 +1,76 @@
<?php
declare(strict_types=1);
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Symfony\Component\HttpFoundation\Response;
final class EnsureSchoolAccess
{
public function handle(Request $request, Closure $next): Response
{
$user = $request->user();
if (! $user) {
abort(401);
}
$schoolId = $this->schoolIdFromRoute($request);
if (! $schoolId) {
abort(404);
}
if (! $this->userCanAccessSchool((int) $user->getAuthIdentifier(), $schoolId)) {
abort(404);
}
return $next($request);
}
private function schoolIdFromRoute(Request $request): ?int
{
$school = $request->route('school');
if (is_numeric($school)) {
return (int) $school;
}
if (is_object($school) && isset($school->id)) {
return (int) $school->id;
}
return null;
}
private function userCanAccessSchool(int $userId, int $schoolId): bool
{
if (Schema::hasTable('school_user')) {
return DB::table('school_user')
->where('user_id', $userId)
->where('school_id', $schoolId)
->exists();
}
if (Schema::hasTable('school_users')) {
return DB::table('school_users')
->where('user_id', $userId)
->where('school_id', $schoolId)
->exists();
}
if (Schema::hasTable('users') && Schema::hasColumn('users', 'school_id')) {
return DB::table('users')
->where('id', $userId)
->where('school_id', $schoolId)
->exists();
}
return false;
}
}
+4 -1
View File
@@ -4,6 +4,7 @@ use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;
use Illuminate\Http\Request;
use App\Http\Middleware\EnsureSchoolAccess;
return Application::configure(basePath: dirname(__DIR__))
->withRouting(
@@ -13,7 +14,9 @@ return Application::configure(basePath: dirname(__DIR__))
health: '/up',
)
->withMiddleware(function (Middleware $middleware): void {
//
$middleware->alias([
'school.access' => EnsureSchoolAccess::class,
]);
})
->withExceptions(function (Exceptions $exceptions): void {
$exceptions->shouldRenderJsonWhen(
@@ -12,6 +12,7 @@ return new class extends Migration
Schema::create('schools', function (Blueprint $table) {
$table->id();
$table->string('name')->default('Default School');
$table->string('code')->nullable()->unique();
$table->string('domain_profile')->default('standard_school');
$table->string('timezone')->default('UTC');
$table->string('locale')->default('en');
@@ -7,6 +7,75 @@ use Illuminate\Support\Facades\Schema;
return new class extends Migration {
public function up(): void
{
if (! Schema::hasTable('invoices')) {
Schema::create('invoices', function (Blueprint $table): void {
$table->id();
$table->unsignedBigInteger('school_id')->index();
$table->unsignedBigInteger('student_id')->nullable()->index();
$table->unsignedBigInteger('guardian_id')->nullable()->index();
$table->string('currency', 3)->default('USD');
$table->integer('total_amount_minor')->default(0);
$table->integer('paid_amount_minor')->default(0);
$table->integer('balance_amount_minor')->default(0);
$table->string('status')->default('draft')->index();
$table->date('due_date')->nullable();
$table->text('notes')->nullable();
$table->text('void_reason')->nullable();
$table->json('metadata')->nullable();
$table->timestamps();
});
}
if (! Schema::hasTable('invoice_items')) {
Schema::create('invoice_items', function (Blueprint $table): void {
$table->id();
$table->unsignedBigInteger('school_id')->index();
$table->unsignedBigInteger('invoice_id')->index();
$table->string('description');
$table->string('type')->default('charge');
$table->integer('amount_minor');
$table->string('currency', 3)->default('USD');
$table->json('metadata')->nullable();
$table->timestamps();
});
}
if (! Schema::hasTable('payments')) {
Schema::create('payments', function (Blueprint $table): void {
$table->id();
$table->unsignedBigInteger('school_id')->index();
$table->unsignedBigInteger('invoice_id')->nullable()->index();
$table->unsignedBigInteger('student_id')->nullable()->index();
$table->unsignedBigInteger('guardian_id')->nullable()->index();
$table->integer('amount_minor');
$table->string('currency', 3)->default('USD');
$table->string('payment_method')->default('external');
$table->date('payment_date')->nullable()->index();
$table->string('reference_number')->nullable()->index();
$table->text('notes')->nullable();
$table->string('status')->default('recorded')->index();
$table->text('reverse_reason')->nullable();
$table->string('idempotency_key')->nullable()->index();
$table->json('metadata')->nullable();
$table->timestamps();
$table->unique(['school_id', 'reference_number'], 'payments_school_reference_unique');
});
}
if (! Schema::hasTable('payment_refunds')) {
Schema::create('payment_refunds', function (Blueprint $table): void {
$table->id();
$table->unsignedBigInteger('school_id')->index();
$table->unsignedBigInteger('invoice_id')->nullable()->index();
$table->unsignedBigInteger('payment_id')->index();
$table->integer('amount_minor');
$table->string('currency', 3)->default('USD');
$table->text('reason')->nullable();
$table->json('metadata')->nullable();
$table->timestamps();
});
}
Schema::create('finance_audit_logs', function (Blueprint $table): void {
$table->id(); $table->unsignedBigInteger('school_id')->index(); $table->string('academic_year_id')->nullable()->index(); $table->string('term_id')->nullable()->index(); $table->unsignedBigInteger('actor_user_id')->index(); $table->json('actor_role_snapshot')->nullable(); $table->string('action')->index(); $table->string('entity_type')->index(); $table->string('entity_id')->index(); $table->json('before_json')->nullable(); $table->json('after_json')->nullable(); $table->string('reason', 1000)->nullable(); $table->string('request_id')->nullable()->index(); $table->string('ip_address')->nullable(); $table->text('user_agent')->nullable(); $table->string('idempotency_key')->nullable()->index(); $table->timestamps(); $table->index(['entity_type','entity_id']);
});
@@ -19,6 +88,6 @@ return new class extends Migration {
}
public function down(): void
{
Schema::dropIfExists('finance_idempotency_keys'); Schema::dropIfExists('payment_files'); Schema::dropIfExists('finance_audit_logs');
Schema::dropIfExists('finance_idempotency_keys'); Schema::dropIfExists('payment_files'); Schema::dropIfExists('finance_audit_logs'); Schema::dropIfExists('payment_refunds'); Schema::dropIfExists('payments'); Schema::dropIfExists('invoice_items'); Schema::dropIfExists('invoices');
}
};
@@ -50,6 +50,18 @@ return new class extends Migration
private function indexExists(string $table, string $indexName): bool
{
if (DB::getDriverName() === 'sqlite') {
$indexes = DB::select('PRAGMA index_list('.$table.')');
foreach ($indexes as $index) {
if (($index->name ?? null) === $indexName) {
return true;
}
}
return false;
}
$database = DB::getDatabaseName();
$result = DB::selectOne(
@@ -22,6 +22,18 @@ return new class extends Migration { public function up(): void
private function indexExists(string $table, string $indexName): bool
{
if (DB::getDriverName() === 'sqlite') {
$indexes = DB::select('PRAGMA index_list('.$table.')');
foreach ($indexes as $index) {
if (($index->name ?? null) === $indexName) {
return true;
}
}
return false;
}
$database = DB::getDatabaseName();
$result = DB::selectOne(
@@ -48,11 +48,11 @@ return new class extends Migration
$table->unsignedBigInteger('school_id');
$table->string('recipient_type', 64);
$table->string('recipient_type', 32);
$table->unsignedBigInteger('recipient_id');
$table->string('channel', 32);
$table->string('message_category', 64)->default('general');
$table->string('message_category', 32)->default('general');
$table->boolean('opted_in')->default(true);
$table->boolean('quiet_hours_enabled')->default(false);
@@ -89,7 +89,7 @@ return new class extends Migration
$table->string('status', 32)->default('draft');
$table->string('channel', 32);
$table->string('message_category', 64)->default('general');
$table->string('message_category', 32)->default('general');
$table->string('language', 16)->default('en');
$table->string('subject', 191)->nullable();
+19
View File
@@ -1,7 +1,26 @@
<?php
use App\Http\Controllers\Api\Integration\SchoolWorkflowController;
use Illuminate\Support\Facades\Route;
Route::post('/schools', [SchoolWorkflowController::class, 'storeSchool']);
Route::middleware(['auth', 'school.access'])->group(function (): void {
Route::get('/schools/{school}', [SchoolWorkflowController::class, 'showSchool']);
Route::post('/schools/{school}/students', [SchoolWorkflowController::class, 'storeStudent']);
Route::get('/schools/{school}/students/{student}', [SchoolWorkflowController::class, 'showStudent']);
Route::post('/schools/{school}/attendance/scans', [SchoolWorkflowController::class, 'storeAttendanceScan']);
Route::post('/schools/{school}/communications/preview', [SchoolWorkflowController::class, 'previewCommunication']);
Route::post('/schools/{school}/communications/send', [SchoolWorkflowController::class, 'sendCommunication']);
Route::post('/schools/{school}/finance/payment-files', [SchoolWorkflowController::class, 'storePaymentFile']);
Route::get('/schools/{school}/reports/daily', [SchoolWorkflowController::class, 'dailyReport']);
});
Route::get('/health', function () {
return response()->json([
'success' => true,
@@ -0,0 +1,333 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Integration;
use Illuminate\Auth\GenericUser;
use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Tests\TestCase;
final class AuthTenantIsolationIntegrationTest extends TestCase
{
use RefreshDatabase;
public function test_user_can_access_own_school_workflows(): void
{
$school = $this->createSchool('Own School', 'OWN');
$user = $this->createActorForSchool($school);
$this->actingAs($user);
$this->getJson("/api/schools/{$school}")
->assertOk();
$studentResponse = $this->postJson("/api/schools/{$school}/students", [
'first_name' => 'Maya',
'last_name' => 'Patel',
'student_number' => 'OWN-1001',
'grade_level' => '7',
]);
$studentResponse->assertCreated();
$studentId = $studentResponse->json('data.id');
$this->getJson("/api/schools/{$school}/students/{$studentId}")
->assertOk()
->assertJsonPath('data.student_number', 'OWN-1001');
$this->postJson("/api/schools/{$school}/attendance/scans", [
'student_number' => 'OWN-1001',
'scanned_at' => '2026-05-29T08:15:00Z',
'source' => 'kiosk',
])->assertCreated();
$this->createTemplate(
$school,
'attendance_absence',
'email',
'Hello, {{ student.first_name }} was marked absent.'
);
$this->postJson("/api/schools/{$school}/communications/preview", [
'template_key' => 'attendance_absence',
'student_id' => $studentId,
'channel' => 'email',
])->assertOk();
$this->postJson("/api/schools/{$school}/communications/send", [
'template_key' => 'attendance_absence',
'student_id' => $studentId,
'channel' => 'email',
])->assertAccepted();
$file = UploadedFile::fake()->createWithContent(
'payments.csv',
"student_number,amount,paid_at,reference\nOWN-1001,125.00,2026-05-29,PAY-OWN-001\n"
);
$this->postJson("/api/schools/{$school}/finance/payment-files", [
'file' => $file,
])->assertCreated();
$this->getJson("/api/schools/{$school}/reports/daily?date=2026-05-29")
->assertOk();
}
public function test_user_cannot_access_another_schools_data(): void
{
$schoolA = $this->createSchool('School A', 'S-A');
$schoolB = $this->createSchool('School B', 'S-B');
$userA = $this->createActorForSchool($schoolA);
$studentB = $this->createStudent($schoolB, 'OTHER-2001', 'Other');
$this->createTemplate(
$schoolB,
'attendance_absence',
'email',
'Hello, {{ student.first_name }} was marked absent.'
);
$this->actingAs($userA);
$this->assertTenantBlocked(
$this->getJson("/api/schools/{$schoolB}")
);
$this->assertTenantBlocked(
$this->postJson("/api/schools/{$schoolB}/students", [
'first_name' => 'Intruder',
'last_name' => 'Student',
'student_number' => 'BAD-1001',
'grade_level' => '8',
])
);
$this->assertTenantBlocked(
$this->getJson("/api/schools/{$schoolB}/students/{$studentB}")
);
$this->assertTenantBlocked(
$this->postJson("/api/schools/{$schoolB}/attendance/scans", [
'student_number' => 'OTHER-2001',
'scanned_at' => '2026-05-29T08:15:00Z',
'source' => 'kiosk',
])
);
$this->assertTenantBlocked(
$this->postJson("/api/schools/{$schoolB}/communications/preview", [
'template_key' => 'attendance_absence',
'student_id' => $studentB,
'channel' => 'email',
])
);
$this->assertTenantBlocked(
$this->postJson("/api/schools/{$schoolB}/communications/send", [
'template_key' => 'attendance_absence',
'student_id' => $studentB,
'channel' => 'email',
])
);
$file = UploadedFile::fake()->createWithContent(
'payments.csv',
"student_number,amount,paid_at,reference\nOTHER-2001,125.00,2026-05-29,PAY-BAD-001\n"
);
$this->assertTenantBlocked(
$this->postJson("/api/schools/{$schoolB}/finance/payment-files", [
'file' => $file,
])
);
$this->assertTenantBlocked(
$this->getJson("/api/schools/{$schoolB}/reports/daily?date=2026-05-29")
);
$this->assertDatabaseMissing('students', [
'school_id' => $schoolB,
'student_identifier' => 'BAD-1001',
]);
$this->assertDatabaseMissing('payments', [
'school_id' => $schoolB,
'reference_number' => 'PAY-BAD-001',
]);
}
public function test_guest_cannot_access_school_workflows(): void
{
$school = $this->createSchool('Guest Block School', 'GST');
$student = $this->createStudent($school, 'GUEST-1001', 'Guest');
$this->createTemplate(
$school,
'attendance_absence',
'email',
'Hello, {{ student.first_name }} was marked absent.'
);
$this->getJson("/api/schools/{$school}")
->assertUnauthorized();
$this->postJson("/api/schools/{$school}/students", [
'first_name' => 'Guest',
'last_name' => 'Student',
'student_number' => 'GUEST-2001',
'grade_level' => '7',
])->assertUnauthorized();
$this->getJson("/api/schools/{$school}/students/{$student}")
->assertUnauthorized();
$this->postJson("/api/schools/{$school}/attendance/scans", [
'student_number' => 'GUEST-1001',
'scanned_at' => '2026-05-29T08:15:00Z',
'source' => 'kiosk',
])->assertUnauthorized();
$this->postJson("/api/schools/{$school}/communications/preview", [
'template_key' => 'attendance_absence',
'student_id' => $student,
'channel' => 'email',
])->assertUnauthorized();
$this->postJson("/api/schools/{$school}/communications/send", [
'template_key' => 'attendance_absence',
'student_id' => $student,
'channel' => 'email',
])->assertUnauthorized();
$file = UploadedFile::fake()->createWithContent(
'payments.csv',
"student_number,amount,paid_at,reference\nGUEST-1001,125.00,2026-05-29,PAY-GUEST-001\n"
);
$this->postJson("/api/schools/{$school}/finance/payment-files", [
'file' => $file,
])->assertUnauthorized();
$this->getJson("/api/schools/{$school}/reports/daily?date=2026-05-29")
->assertUnauthorized();
}
private function createSchool(string $name = 'Test School', ?string $code = null): int
{
return (int) DB::table('schools')->insertGetId([
'name' => $name,
'code' => $code ?? 'SCH-'.uniqid(),
'domain_profile' => 'standard_school',
'timezone' => 'UTC',
'locale' => 'en',
'currency' => 'USD',
'active' => true,
'created_at' => now(),
'updated_at' => now(),
]);
}
private function createStudent(int $school, string $studentNumber, string $firstName = 'Maya'): int
{
return (int) DB::table('students')->insertGetId([
'school_id' => $school,
'school_id_number' => $studentNumber,
'student_identifier' => $studentNumber,
'first_name' => $firstName,
'last_name' => 'Patel',
'status' => 'active',
'profile_metadata' => json_encode(['grade_level' => '7'], JSON_THROW_ON_ERROR),
'metadata' => json_encode(['grade_level' => '7'], JSON_THROW_ON_ERROR),
'created_at' => now(),
'updated_at' => now(),
]);
}
private function createTemplate(int $school, string $key, string $channel, string $body): int
{
return (int) DB::table('communication_templates')->insertGetId([
'school_id' => $school,
'template_key' => $key,
'version' => '1',
'status' => 'active',
'channel' => $channel,
'message_category' => 'general',
'language' => 'en',
'subject' => 'Notice for {{ student.first_name }}',
'body' => $body,
'created_at' => now(),
'updated_at' => now(),
]);
}
private function createActorForSchool(int $school): Authenticatable
{
$this->ensureAuthTestTablesExist();
$userId = (int) DB::table('users')->insertGetId([
'name' => 'Test User '.$school,
'email' => 'user'.$school.'@example.test',
'password' => bcrypt('password'),
'created_at' => now(),
'updated_at' => now(),
]);
DB::table('school_user')->insert([
'school_id' => $school,
'user_id' => $userId,
'role' => 'admin',
'created_at' => now(),
'updated_at' => now(),
]);
return new GenericUser([
'id' => $userId,
'name' => 'Test User '.$school,
'email' => 'user'.$school.'@example.test',
]);
}
private function ensureAuthTestTablesExist(): void
{
if (! Schema::hasTable('users')) {
Schema::create('users', function ($table): void {
$table->id();
$table->string('name');
$table->string('email')->unique();
$table->string('password');
$table->rememberToken();
$table->timestamps();
});
}
if (! Schema::hasTable('school_user')) {
Schema::create('school_user', function ($table): void {
$table->id();
$table->unsignedBigInteger('school_id')->index();
$table->unsignedBigInteger('user_id')->index();
$table->string('role')->default('admin');
$table->timestamps();
$table->unique(['school_id', 'user_id']);
});
}
}
private function assertTenantBlocked($response): void
{
$this->assertContains(
$response->getStatusCode(),
[403, 404],
'Cross-tenant access must return either 403 or 404.'
);
}
}
@@ -0,0 +1,316 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Integration;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\DB;
use Illuminate\Auth\GenericUser;
use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Support\Facades\Schema;
use Tests\TestCase;
final class SchoolWorkflowIntegrationTest extends TestCase
{
use RefreshDatabase;
public function test_school_can_be_created_and_read_through_http(): void
{
$response = $this->postJson('/api/schools', [
'name' => 'North Ridge School',
'code' => 'NRS',
'timezone' => 'America/New_York',
]);
$response->assertCreated()
->assertJsonPath('data.name', 'North Ridge School')
->assertJsonPath('data.code', 'NRS');
$schoolId = (int) $response->json('data.id');
$this->actingAsSchoolUser($schoolId);
$this->getJson("/api/schools/{$schoolId}")
->assertOk()
->assertJsonPath('data.timezone', 'America/New_York');
$this->assertDatabaseHas('schools', [
'id' => $schoolId,
'code' => 'NRS',
]);
}
public function test_student_can_be_created_for_school_and_isolated_by_school(): void
{
$schoolA = $this->createSchool('School A', 'S-A');
$schoolB = $this->createSchool('School B', 'S-B');
$response = $this->postJson("/api/schools/{$schoolA}/students", [
'first_name' => 'Maya',
'last_name' => 'Patel',
'student_number' => 'STU-1001',
'grade_level' => '7',
]);
$response->assertCreated()
->assertJsonPath('data.first_name', 'Maya')
->assertJsonPath('data.student_number', 'STU-1001');
$studentId = $response->json('data.id');
$this->getJson("/api/schools/{$schoolA}/students/{$studentId}")
->assertOk()
->assertJsonPath('data.grade_level', '7');
$this->getJson("/api/schools/{$schoolB}/students/{$studentId}")
->assertNotFound();
}
public function test_attendance_scan_records_once_and_duplicate_scan_is_idempotent(): void
{
$school = $this->createSchool();
$this->actingAsSchoolUser($school);
$student = $this->createStudent($school, 'STU-2001');
$payload = [
'student_number' => 'STU-2001',
'scanned_at' => '2026-05-29T08:15:00Z',
'source' => 'kiosk',
];
$this->postJson("/api/schools/{$school}/attendance/scans", $payload)
->assertCreated()
->assertJsonPath('data.status', 'recorded');
$this->postJson("/api/schools/{$school}/attendance/scans", $payload)
->assertOk()
->assertJsonPath('data.status', 'duplicate');
$this->assertDatabaseHas('attendance_records', [
'school_id' => $school,
'subject_type' => 'student',
'subject_id' => $student,
'status' => 'present',
]);
$this->assertSame(1, DB::table('attendance_records')->where('school_id', $school)->where('subject_id', $student)->count());
$this->assertSame(2, DB::table('attendance_scan_logs')->where('school_id', $school)->where('subject_id', $student)->count());
}
public function test_communication_preview_has_no_side_effect_and_send_queues_recipient(): void
{
$school = $this->createSchool();
$this->actingAsSchoolUser($school);
$student = $this->createStudent($school, 'STU-3001', 'Maya');
$this->createTemplate($school, 'attendance_absence', 'email', 'Hello, {{ student.first_name }} was marked absent.');
$this->postJson("/api/schools/{$school}/communications/preview", [
'template_key' => 'attendance_absence',
'student_id' => $student,
'channel' => 'email',
])->assertOk()
->assertJsonPath('data.body', 'Hello, Maya was marked absent.');
$this->assertDatabaseCount('communication_messages', 0);
$this->assertDatabaseCount('communication_message_recipients', 0);
$this->postJson("/api/schools/{$school}/communications/send", [
'template_key' => 'attendance_absence',
'student_id' => $student,
'channel' => 'email',
])->assertAccepted()
->assertJsonPath('data.status', 'queued');
$this->assertDatabaseHas('communication_messages', [
'school_id' => $school,
'channel' => 'email',
'status' => 'queued',
]);
$this->assertDatabaseHas('communication_message_recipients', [
'school_id' => $school,
'recipient_type' => 'student',
'recipient_id' => $student,
'status' => 'queued',
]);
}
public function test_payment_file_imports_valid_rows_and_rejects_invalid_rows_without_partial_import(): void
{
$school = $this->createSchool();
$this->actingAsSchoolUser($school);
$student = $this->createStudent($school, 'STU-4001');
$validFile = UploadedFile::fake()->createWithContent(
'payments.csv',
"student_number,amount,paid_at,reference\nSTU-4001,125.00,2026-05-29,PAY-001\n"
);
$this->postJson("/api/schools/{$school}/finance/payment-files", [
'file' => $validFile,
])->assertCreated()
->assertJsonPath('data.imported_count', 1);
$this->assertDatabaseHas('payments', [
'school_id' => $school,
'student_id' => $student,
'amount_minor' => 12500,
'reference_number' => 'PAY-001',
]);
$invalidFile = UploadedFile::fake()->createWithContent(
'bad-payments.csv',
"student_number,amount,paid_at,reference\nSTU-4001,80.00,2026-05-29,PAY-002\nMISSING,90.00,2026-05-29,PAY-003\n"
);
$this->postJson("/api/schools/{$school}/finance/payment-files", [
'file' => $invalidFile,
])->assertUnprocessable();
$this->assertDatabaseMissing('payments', [
'school_id' => $school,
'reference_number' => 'PAY-002',
]);
$this->assertDatabaseMissing('payments', [
'school_id' => $school,
'reference_number' => 'PAY-003',
]);
}
public function test_daily_report_aggregates_attendance_and_finance(): void
{
$school = $this->createSchool();
$this->actingAsSchoolUser($school);
$student = $this->createStudent($school, 'STU-5001');
$this->postJson("/api/schools/{$school}/attendance/scans", [
'student_number' => 'STU-5001',
'scanned_at' => '2026-05-29T08:15:00Z',
'source' => 'kiosk',
])->assertCreated();
DB::table('payments')->insert([
'school_id' => $school,
'student_id' => $student,
'amount_minor' => 12500,
'currency' => 'USD',
'payment_method' => 'manual',
'payment_date' => '2026-05-29',
'reference_number' => 'PAY-RPT-001',
'status' => 'recorded',
'created_at' => now(),
'updated_at' => now(),
]);
$this->getJson("/api/schools/{$school}/reports/daily?date=2026-05-29")
->assertOk()
->assertJsonPath('data.attendance.present_count', 1)
->assertJsonPath('data.finance.total_payments', 12500);
}
private function createSchool(string $name = 'Test School', ?string $code = null): int
{
return (int) DB::table('schools')->insertGetId([
'name' => $name,
'code' => $code ?? 'SCH-'.uniqid(),
'domain_profile' => 'standard_school',
'timezone' => 'UTC',
'locale' => 'en',
'currency' => 'USD',
'active' => true,
'created_at' => now(),
'updated_at' => now(),
]);
}
private function createStudent(int $school, string $studentNumber, string $firstName = 'Maya'): int
{
return (int) DB::table('students')->insertGetId([
'school_id' => $school,
'school_id_number' => $studentNumber,
'student_identifier' => $studentNumber,
'first_name' => $firstName,
'last_name' => 'Patel',
'status' => 'active',
'profile_metadata' => json_encode(['grade_level' => '7'], JSON_THROW_ON_ERROR),
'metadata' => json_encode(['grade_level' => '7'], JSON_THROW_ON_ERROR),
'created_at' => now(),
'updated_at' => now(),
]);
}
private function createTemplate(int $school, string $key, string $channel, string $body): int
{
return (int) DB::table('communication_templates')->insertGetId([
'school_id' => $school,
'template_key' => $key,
'version' => '1',
'status' => 'active',
'channel' => $channel,
'message_category' => 'general',
'language' => 'en',
'subject' => 'Notice for {{ student.first_name }}',
'body' => $body,
'created_at' => now(),
'updated_at' => now(),
]);
}
private function actingAsSchoolUser(int $school): Authenticatable
{
$this->ensureAuthTestTablesExist();
$userId = (int) DB::table('users')->insertGetId([
'name' => 'Workflow User '.$school,
'email' => 'workflow'.$school.'@example.test',
'password' => bcrypt('password'),
'created_at' => now(),
'updated_at' => now(),
]);
DB::table('school_user')->insert([
'school_id' => $school,
'user_id' => $userId,
'role' => 'admin',
'created_at' => now(),
'updated_at' => now(),
]);
$user = new GenericUser([
'id' => $userId,
'name' => 'Workflow User '.$school,
'email' => 'workflow'.$school.'@example.test',
]);
$this->actingAs($user);
return $user;
}
private function ensureAuthTestTablesExist(): void
{
if (! Schema::hasTable('users')) {
Schema::create('users', function ($table): void {
$table->id();
$table->string('name');
$table->string('email')->unique();
$table->string('password');
$table->rememberToken();
$table->timestamps();
});
}
if (! Schema::hasTable('school_user')) {
Schema::create('school_user', function ($table): void {
$table->id();
$table->unsignedBigInteger('school_id')->index();
$table->unsignedBigInteger('user_id')->index();
$table->string('role')->default('admin');
$table->timestamps();
$table->unique(['school_id', 'user_id']);
});
}
}
}