examRow([ 'id' => 12, 'status' => 'accepted', 'review_revision' => 2, 'acceptance_type' => 'minor_edits', 'reviewed_at' => '2026-05-02 12:00:00', 'final_file' => 'final-v2.docx', 'final_filename' => 'Grade 3 Final.docx', 'final_pdf_file' => 'final-v2.pdf', ]), $this->examRow([ 'id' => 11, 'status' => 'under review', 'review_revision' => 1, 'reviewed_at' => '2026-05-01 12:00:00', 'final_file' => 'review-v1.docx', ]), $this->examRow([ 'id' => 10, 'status' => 'submitted', 'review_revision' => 0, 'teacher_file' => 'teacher-v1.docx', ]), ]; $grouped = $this->invoke('groupReviewRevisions', [$rows]); $this->assertCount(1, $grouped); $this->assertSame('accepted', $grouped[0]['status']); $this->assertSame(2, $grouped[0]['review_revision']); $this->assertSame('minor_edits', $grouped[0]['acceptance_type']); $this->assertSame('final-v2.docx', $grouped[0]['final_file']); $this->assertSame('final-v2.pdf', $grouped[0]['final_pdf_file']); $this->assertCount(2, $grouped[0]['review_files']); } public function testGradeArchiveCombinesLegacyCopiesAcrossSections(): void { $rows = [ $this->examRow([ 'id' => 1, 'class_id' => 3, 'class_name' => '3', 'class_section_id' => 30, 'class_section_name' => '3', 'school_year' => '2024-2025', 'status' => 'legacy', 'final_file' => 'grade-3-final.pdf', ]), $this->examRow([ 'id' => 2, 'class_id' => 3, 'class_name' => '3', 'class_section_id' => 31, 'class_section_name' => '3-A', 'school_year' => '2024-2025', 'status' => 'legacy', 'final_file' => 'grade-3-final.pdf', ]), ]; $groups = $this->invoke('groupArchivedExamsByGrade', [$rows]); $this->assertArrayHasKey(3, $groups); $this->assertSame('Grade 3', $groups[3]['class_section_name']); $this->assertCount(1, $groups[3]['items']); $this->assertSame(['3', '3-A'], $groups[3]['items'][0]['class_section_names']); } public function testSubmissionsAreScopedToYearWhileAcceptedRecordsAreAlsoArchived(): void { $rows = [ $this->examRow(['id' => 1, 'school_year' => '2025-2026', 'status' => 'submitted']), $this->examRow(['id' => 2, 'school_year' => '2024-2025', 'status' => 'under review']), $this->examRow(['id' => 3, 'school_year' => '2025-2026', 'status' => 'accepted']), $this->examRow(['id' => 4, 'school_year' => '2024-2025', 'status' => 'legacy']), ]; [$drafts, $archived] = $this->invoke('partitionExamDraftsByYear', [$rows, '2025-2026']); $this->assertSame([1, 3], array_column($drafts, 'id')); $this->assertSame([3, 4], array_column($archived, 'id')); } public function testFinalArchiveKeepsOnlyLatestAcceptedTeacherVersion(): void { $rows = [ $this->examRow(['id' => 10, 'status' => 'accepted', 'version' => 1, 'review_revision' => 3]), $this->examRow(['id' => 11, 'status' => 'accepted', 'version' => 2, 'review_revision' => 1]), $this->examRow(['id' => 12, 'status' => 'legacy', 'version' => 1]), ]; $archive = $this->invoke('latestFinalArchiveRows', [$rows]); $this->assertSame([12, 11], array_column($archive, 'id')); } public function testFinalArchiveLinksArePdfOnly(): void { $view = file_get_contents(ROOTPATH . 'app/Views/administrator/exam_drafts.php') ?: ''; $this->assertStringContainsString("$" . "archivePdfFile = trim((string) ($" . "item['final_pdf_file']", $view); $this->assertStringContainsString('View PDF', $view); $this->assertStringContainsString('Download PDF', $view); $this->assertStringContainsString('Upload replacement PDF', $view); $this->assertStringContainsString('automatic conversion is unavailable', $view); } public function testLossyPhpWordPdfFallbackIsNotUsedForFinalArchive(): void { $controller = file_get_contents(ROOTPATH . 'app/Controllers/View/ExamDraftController.php') ?: ''; $this->assertStringNotContainsString('PDF_RENDERER_DOMPDF', $controller); $this->assertStringContainsString('EXAM_PDF_CONVERTER_BINARY', $controller); $this->assertStringContainsString('STIRLING_PDF_URL', $controller); $this->assertStringContainsString('/api/v1/convert/file/pdf', $controller); $this->assertStringContainsString('-env:UserInstallation=file://', $controller); $this->assertStringNotContainsString('Legacy exams must be uploaded as PDF', $controller); } public function testGeneratedPdfBesideDocxIsRejectedAsLossy(): void { $directory = WRITEPATH . 'uploads/exams/finals'; $base = 'conversion-safety-' . uniqid('', true); $pdf = $directory . '/' . $base . '.pdf'; $docx = $directory . '/' . $base . '.docx'; $archivePdf = $directory . '/' . $base . '.archive.pdf'; file_put_contents($pdf, '%PDF unsafe'); file_put_contents($docx, 'source'); file_put_contents($archivePdf, '%PDF safe'); try { $this->assertFalse($this->invoke('isFormattingSafePdf', [basename($pdf)])); $this->assertTrue($this->invoke('isFormattingSafePdf', [basename($archivePdf)])); unlink($docx); $this->assertTrue($this->invoke('isFormattingSafePdf', [basename($pdf)])); } finally { foreach ([$pdf, $docx, $archivePdf] as $path) { if (is_file($path)) { unlink($path); } } } } /** @return array */ private function examRow(array $overrides): array { return array_merge([ 'id' => 1, 'teacher_id' => 20, 'class_section_id' => 30, 'class_section_name' => '3', 'semester' => 'Spring', 'school_year' => '2025-2026', 'exam_type' => 'Final Exam', 'version' => 1, 'review_revision' => 0, 'status' => 'submitted', 'final_file' => null, 'final_filename' => null, 'final_pdf_file' => null, ], $overrides); } private function invoke(string $method, array $arguments): mixed { $controller = (new ReflectionClass(ExamDraftController::class))->newInstanceWithoutConstructor(); $reflection = new ReflectionMethod($controller, $method); $reflection->setAccessible(true); return $reflection->invokeArgs($controller, $arguments); } }