'List merge scenarios without executing.', '--keep' => 'Skip cleanup of S3 objects and claim_files rows.', ]; private int $pass = 0; private int $fail = 0; private int $skip = 0; /** @var list */ private array $catalog = [ ['id' => 'MERGE-01', 'title' => 'Create & upload 2 source PDFs to S3 (claim_files, no permanent local)'], ['id' => 'MERGE-02', 'title' => 'Insert claim_files DB rows for synthetic ticket'], ['id' => 'MERGE-03', 'title' => 'merge_ticket_pdfs resolves sources from S3 (temp download)'], ['id' => 'MERGE-04', 'title' => 'Merge succeeds with pages >= 2'], ['id' => 'MERGE-05', 'title' => 'Merged PDF exists on S3'], ['id' => 'MERGE-06', 'title' => 'Merged permanent local path gone when S3 enabled'], ['id' => 'MERGE-07', 'title' => 'Merged claim_files row registered (file_type=4)'], ['id' => 'MERGE-08', 'title' => 'Claim runtime temps cleaned after merge'], ['id' => 'MERGE-09', 'title' => 'Cleanup removes S3 objects + soft-deletes DB rows'], ]; public function run(array $params) { helper(['utility', 'merge_pdf']); if (CLI::getOption('list') !== null) { CLI::write('========== MERGE PDF SCENARIOS ==========', 'cyan'); foreach ($this->catalog as $row) { CLI::write(" {$row['id']} {$row['title']}"); } CLI::newLine(); CLI::write('Run: php spark storage:smoke-test-merge-pdf'); return EXIT_SUCCESS; } $storage = \Config\Services::getFileStorageService(false); $keep = CLI::getOption('keep') !== null; CLI::write('========== CLAIM PDF MERGE SMOKE ==========', 'cyan'); CLI::write('usesS3: ' . ($storage->usesS3() ? 'YES' : 'NO')); CLI::write('Bucket: ' . ($storage->getBucket() ?? 'N/A')); CLI::write('RETAIN_LOCAL: ' . (storage_env_retain_local() ? 'true' : 'false')); CLI::newLine(); if (! $storage->usesS3()) { foreach ($this->catalog as $row) { $this->skip($row['id'], 'FILE_STORAGE_DRIVER must be s3'); } $this->printResult(); return EXIT_ERROR; } $uploadDir = rtrim(WRITEPATH, '/\\') . DIRECTORY_SEPARATOR . 'uploads' . DIRECTORY_SEPARATOR . 'claim_files' . DIRECTORY_SEPARATOR; if (! is_dir($uploadDir)) { @mkdir($uploadDir, 0775, true); } $ticketId = 990000000 + random_int(1000, 999999); $stamp = date('Ymd_His') . '_' . bin2hex(random_bytes(3)); $srcNames = [ 'smoke_merge_a_' . $stamp . '.pdf', 'smoke_merge_b_' . $stamp . '.pdf', ]; $sourceIds = []; $mergedName = null; $mergedId = null; $claimFiles = new ClaimFilesModel(); try { // MERGE-01 $uploaded = 0; foreach ($srcNames as $i => $name) { $local = $this->makeOnePagePdf($uploadDir, $name, 'Smoke source ' . ($i + 1)); if ($local === null || ! is_file($local)) { continue; } $up = $storage->upload($local, rtrim($uploadDir, '/\\'), $name); @unlink($local); if (($up['success'] ?? false) && $storage->exists(rtrim($uploadDir, '/\\'), $name) && ! is_file($uploadDir . $name)) { $uploaded++; } } $this->step('MERGE-01', $uploaded === 2, "uploaded={$uploaded}/2 ticket_id={$ticketId}"); if ($uploaded !== 2) { foreach (array_slice($this->catalog, 1) as $row) { $this->skip($row['id'], 'source upload failed'); } $this->printResult(); return EXIT_ERROR; } // MERGE-02 foreach ($srcNames as $idx => $name) { $id = $claimFiles->insert([ 'ticket_id' => $ticketId, 'file_type' => 2, 'doc_name' => 'SMOKE_MERGE_SRC_' . ($idx + 1), 'file_name' => $name, 'url' => $name, 'mime_type' => 'application/pdf', 'is_active' => 1, 'created_by' => 1, ]); if ($id) { $sourceIds[] = (int) $id; } } $this->step('MERGE-02', count($sourceIds) === 2, 'ids=' . implode(',', $sourceIds)); if (count($sourceIds) !== 2) { foreach (array_slice($this->catalog, 2) as $row) { $this->skip($row['id'], 'DB insert failed'); } $this->cleanup($storage, $uploadDir, $srcNames, null, $ticketId, $sourceIds, $keep); $this->printResult(); return EXIT_ERROR; } // MERGE-03..07 via merge_ticket_pdfs $result = merge_ticket_pdfs($ticketId, [ 'replace' => true, 'created_by' => 1, 'include_file_types' => [1, 2], 'include_mime_types' => ['application/pdf', 'image/jpeg', 'image/png'], ]); $this->step( 'MERGE-03', (bool) ($result['status'] ?? false) && (int) ($result['source_count'] ?? 0) >= 2, (string) ($result['message'] ?? '') . ' sources=' . (int) ($result['source_count'] ?? 0) ); $pages = (int) ($result['pages'] ?? 0); $this->step( 'MERGE-04', (bool) ($result['status'] ?? false) && $pages >= 2, 'pages=' . $pages ); $mergedName = (string) ($result['file_name'] ?? ''); $mergedId = $result['merged_file_id'] ?? null; $onS3 = $mergedName !== '' && $storage->exists(rtrim($uploadDir, '/\\'), $mergedName); $this->step('MERGE-05', $onS3, $mergedName !== '' ? $mergedName : 'no merged name'); $localMergedGone = $mergedName === '' || ! is_file($uploadDir . $mergedName); $this->step('MERGE-06', $localMergedGone, $mergedName !== '' ? basename($mergedName) : 'n/a'); $rowOk = false; if ($mergedId) { $row = $claimFiles->find((int) $mergedId); $rowOk = is_array($row) && (int) ($row['file_type'] ?? 0) === MERGED_CLAIM_FILE_TYPE && (int) ($row['is_active'] ?? 0) === 1 && (string) ($row['file_name'] ?? '') === $mergedName; } $this->step('MERGE-07', $rowOk, 'merged_file_id=' . ($mergedId ?? 'null')); // MERGE-08: no leftover smoke temps for our source names in claim_files_runtime $afterRuntime = $this->listClaimRuntimeFiles(); $leaked = []; foreach ($afterRuntime as $f) { foreach ($srcNames as $src) { if (strpos($f, $src) !== false) { $leaked[] = $f; } } } $this->step('MERGE-08', $leaked === [], $leaked === [] ? 'no leaks' : implode(',', $leaked)); // MERGE-09 cleanup $cleanOk = $this->cleanup( $storage, $uploadDir, $srcNames, $mergedName !== '' ? $mergedName : null, $ticketId, $sourceIds, $keep, $mergedId ? [(int) $mergedId] : [] ); if ($keep) { $this->skip('MERGE-09', '--keep set; left ticket_id=' . $ticketId); } else { $srcGone = true; foreach ($srcNames as $name) { if ($storage->exists(rtrim($uploadDir, '/\\'), $name)) { $srcGone = false; } } $mergedGone = $mergedName === '' || ! $storage->exists(rtrim($uploadDir, '/\\'), $mergedName); $this->step('MERGE-09', $cleanOk && $srcGone && $mergedGone, 'ticket_id=' . $ticketId); } } catch (\Throwable $e) { CLI::error('Unhandled: ' . $e->getMessage()); $this->fail++; $this->cleanup($storage, $uploadDir, $srcNames, $mergedName, $ticketId, $sourceIds, $keep, $mergedId ? [(int) $mergedId] : []); } $this->printResult(); return $this->fail > 0 ? EXIT_ERROR : EXIT_SUCCESS; } private function makeOnePagePdf(string $uploadDir, string $fileName, string $text): ?string { $tempDir = rtrim(WRITEPATH, '/\\') . DIRECTORY_SEPARATOR . 'cache' . DIRECTORY_SEPARATOR . 'mpdf'; if (! is_dir($tempDir)) { @mkdir($tempDir, 0775, true); } $path = $uploadDir . $fileName; try { $mpdf = new Mpdf(['tempDir' => $tempDir, 'mode' => 'utf-8']); $mpdf->WriteHTML('

' . htmlspecialchars($text, ENT_QUOTES, 'UTF-8') . '

storage merge smoke

'); $mpdf->Output($path, Destination::FILE); return is_file($path) ? $path : null; } catch (\Throwable $e) { CLI::error('PDF create failed: ' . $e->getMessage()); return null; } } /** @return list */ private function listClaimRuntimeFiles(): array { $dir = rtrim(WRITEPATH, '/\\') . DIRECTORY_SEPARATOR . 'cache' . DIRECTORY_SEPARATOR . 'claim_files_runtime' . DIRECTORY_SEPARATOR; if (! is_dir($dir)) { return []; } $out = []; foreach (scandir($dir) ?: [] as $f) { if ($f === '.' || $f === '..') { continue; } if (is_file($dir . $f)) { $out[] = $f; } } return $out; } /** * @param list $srcNames * @param list $sourceIds * @param list $extraIds */ private function cleanup( $storage, string $uploadDir, array $srcNames, ?string $mergedName, int $ticketId, array $sourceIds, bool $keep, array $extraIds = [] ): bool { if ($keep) { return true; } $ok = true; $dir = rtrim($uploadDir, '/\\'); foreach ($srcNames as $name) { if ($storage->exists($dir, $name)) { $del = $storage->delete($dir, $name); if (! ($del['success'] ?? false)) { $ok = false; } } @unlink($uploadDir . $name); } if ($mergedName) { if ($storage->exists($dir, $mergedName)) { $del = $storage->delete($dir, $mergedName); if (! ($del['success'] ?? false)) { $ok = false; } } @unlink($uploadDir . $mergedName); } try { $claimFiles = new ClaimFilesModel(); $ids = array_values(array_unique(array_merge($sourceIds, $extraIds))); if ($ids !== []) { $claimFiles->whereIn('id', $ids)->set(['is_active' => 0])->update(); } $claimFiles->where('ticket_id', $ticketId)->set(['is_active' => 0])->update(); } catch (\Throwable $e) { CLI::write('DB cleanup warning: ' . $e->getMessage(), 'yellow'); $ok = false; } return $ok; } private function step(string $id, bool $ok, string $detail = ''): void { $title = $this->titleFor($id); if ($ok) { $this->pass++; CLI::write("PASS {$id} {$title}" . ($detail !== '' ? " — {$detail}" : ''), 'green'); } else { $this->fail++; CLI::write("FAIL {$id} {$title}" . ($detail !== '' ? " — {$detail}" : ''), 'red'); } } private function skip(string $id, string $reason): void { $this->skip++; CLI::write("SKIP {$id} {$this->titleFor($id)} — {$reason}", 'yellow'); } private function titleFor(string $id): string { foreach ($this->catalog as $row) { if ($row['id'] === $id) { return $row['title']; } } return $id; } private function printResult(): void { CLI::newLine(); CLI::write( sprintf('MERGE RESULT: %d passed | %d failed | %d skipped', $this->pass, $this->fail, $this->skip), $this->fail === 0 ? 'green' : 'red' ); } }