nhance/app/Commands/StorageMergePdfSmokeTest.php
2026-07-15 15:49:13 +05:30

362 lines
13 KiB
PHP

<?php
namespace App\Commands;
use App\Models\ClaimFilesModel;
use CodeIgniter\CLI\BaseCommand;
use CodeIgniter\CLI\CLI;
use Mpdf\Mpdf;
use Mpdf\Output\Destination;
/**
* Dedicated smoke test for claim PDF merge with S3-backed claim_files.
*
* Usage:
* php spark storage:smoke-test-merge-pdf
* php spark storage:smoke-test-merge-pdf --list
* php spark storage:smoke-test-merge-pdf --keep # leave S3/DB artifacts (debug)
*/
class StorageMergePdfSmokeTest extends BaseCommand
{
protected $group = 'Storage';
protected $name = 'storage:smoke-test-merge-pdf';
protected $description = 'Run dedicated merge_ticket_pdfs S3 scenarios (claim_files).';
protected $usage = 'storage:smoke-test-merge-pdf [--list] [--keep]';
protected $options = [
'--list' => '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<array{id:string,title:string}> */
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('<h1>' . htmlspecialchars($text, ENT_QUOTES, 'UTF-8') . '</h1><p>storage merge smoke</p>');
$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<string> */
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<string> $srcNames
* @param list<int> $sourceIds
* @param list<int> $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'
);
}
}