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

515 lines
21 KiB
PHP

<?php
namespace App\Commands;
use CodeIgniter\CLI\BaseCommand;
use CodeIgniter\CLI\CLI;
/**
* Runs ALL automatable S3/storage scenarios in one command.
*
* Usage:
* php spark storage:smoke-test
* php spark storage:smoke-test --module excel
* php spark storage:smoke-test --list # list all scenarios without running
*/
class StorageSmokeTest extends BaseCommand
{
protected $group = 'Storage';
protected $name = 'storage:smoke-test';
protected $description = 'Run every automatable storage scenario (all modules) and list remaining manual ones.';
protected $usage = 'storage:smoke-test [--module all|excel|claim_files|...] [--list]';
protected $options = [
'--module' => 'One folder name, or all (default: all wired folders).',
'--list' => 'List every scenario ID without executing.',
];
private int $pass = 0;
private int $fail = 0;
private int $skip = 0;
/** @var list<array{id:string,title:string,group:string}> */
private array $catalog = [];
/** @var list<string> */
private array $wiredModules = [
'claim_files',
'client_kyc_documents',
'excel',
'import_excel',
'lead_files',
'attachments',
'claim_dump_excel',
'claims_mis',
'claims_dump',
'statements',
'bds_dump_excel',
'hr_files',
'non_eb_asset_files',
'non_eb_rack_rate',
];
public function run(array $params)
{
helper('utility');
$this->buildCatalog();
if (CLI::getOption('list') !== null) {
$this->printCatalog();
return EXIT_SUCCESS;
}
$storage = \Config\Services::getFileStorageService(false);
$moduleOpt = strtolower((string) (CLI::getOption('module') ?? 'all'));
$modules = $moduleOpt === 'all' || $moduleOpt === ''
? $this->wiredModules
: [basename($moduleOpt)];
CLI::write('========== STORAGE FULL SCENARIO RUN ==========', '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::write('Modules: ' . implode(', ', $modules));
CLI::write('Total catalog scenarios: ' . count($this->catalog));
CLI::newLine();
// ENV / helper checks
$this->runEnvScenarios($storage);
// Per-module full lifecycle for every wired folder
foreach ($modules as $mod) {
$this->runModuleLifecycle($storage, $mod);
}
// Cross-cutting claim / mail / mirror / soft-delete
$this->runClaimScenarios($storage);
$this->runMailScenarios($storage);
$this->runMirrorScenario($storage);
$this->runRetainLocalSimulation($storage);
$this->runClaimsDumpResolve($storage);
CLI::newLine();
CLI::write(
sprintf('RESULT: %d passed | %d failed | %d skipped', $this->pass, $this->fail, $this->skip),
$this->fail === 0 ? 'green' : 'red'
);
CLI::newLine();
$this->printManualOnlyRemaining();
return $this->fail > 0 ? EXIT_ERROR : EXIT_SUCCESS;
}
private function buildCatalog(): void
{
$this->catalog = [
['id' => 'ENV-01', 'group' => 'ENV', 'title' => 'FILE_STORAGE / bucket / RETAIN_LOCAL readable'],
['id' => 'ENV-02', 'group' => 'ENV', 'title' => 'Runtime temp dirs writable'],
['id' => 'ENV-03', 'group' => 'ENV', 'title' => 'usesS3 matches FILE_STORAGE_DRIVER'],
];
foreach ($this->wiredModules as $mod) {
$this->catalog[] = ['id' => "MOD-{$mod}-01", 'group' => "MODULE:{$mod}", 'title' => 'Upload to S3/local'];
$this->catalog[] = ['id' => "MOD-{$mod}-02", 'group' => "MODULE:{$mod}", 'title' => 'Exists after upload'];
$this->catalog[] = ['id' => "MOD-{$mod}-03", 'group' => "MODULE:{$mod}", 'title' => 'Download content'];
$this->catalog[] = ['id' => "MOD-{$mod}-04", 'group' => "MODULE:{$mod}", 'title' => 'ensure_local from storage'];
$this->catalog[] = ['id' => "MOD-{$mod}-05", 'group' => "MODULE:{$mod}", 'title' => 'No permanent local when RETAIN_LOCAL=false (S3)'];
$this->catalog[] = ['id' => "MOD-{$mod}-06", 'group' => "MODULE:{$mod}", 'title' => 'Delete removes object'];
$this->catalog[] = ['id' => "MOD-{$mod}-07", 'group' => "MODULE:{$mod}", 'title' => 'Presigned URL (S3 only)'];
}
$extra = [
['id' => 'CLAIM-01', 'group' => 'CLAIM', 'title' => 'resolve_claim_file_path'],
['id' => 'CLAIM-02', 'group' => 'CLAIM', 'title' => 'claim_file_download_url (TPA)'],
['id' => 'CLAIM-03', 'group' => 'CLAIM', 'title' => 'soft_delete cleans S3 when unused (DB)'],
['id' => 'MERGE-PDF', 'group' => 'CLAIM', 'title' => 'Dedicated PDF merge suite — php spark storage:smoke-test-merge-pdf'],
['id' => 'MAIL-01', 'group' => 'MAIL', 'title' => 'resolve_mail_attachment_path'],
['id' => 'GEN-01', 'group' => 'GENERATED', 'title' => 'mirror_generated_file then local gone'],
['id' => 'RETAIN-01', 'group' => 'RETAIN_LOCAL', 'title' => 'Simulate retain=false (S3-only)'],
['id' => 'RETAIN-02', 'group' => 'RETAIN_LOCAL', 'title' => 'Simulate retain=true (dual-write keep local)'],
['id' => 'DUMP-01', 'group' => 'CLAIMS_DUMP', 'title' => 'resolveDumpLocalPath / ensure_local'],
];
foreach ($extra as $row) {
$this->catalog[] = $row;
}
}
private function printCatalog(): void
{
CLI::write('========== ALL SCENARIO TEST CASES ==========', 'cyan');
$current = '';
foreach ($this->catalog as $row) {
if ($row['group'] !== $current) {
$current = $row['group'];
CLI::newLine();
CLI::write("--- {$current} ---", 'yellow');
}
CLI::write(" {$row['id']} {$row['title']}");
}
CLI::newLine();
CLI::write('Manual-only (UI/TPA jobs — run by human after automated):', 'yellow');
foreach ($this->manualScenarioLines() as $line) {
CLI::write(' ' . $line);
}
CLI::newLine();
CLI::write('Run all automated: php spark storage:smoke-test');
}
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 uploadPath(string $mod): string
{
return rtrim(WRITEPATH, '/\\') . DIRECTORY_SEPARATOR . 'uploads' . DIRECTORY_SEPARATOR . $mod;
}
private function makeTmp(string $name, string $body): string
{
$dir = storage_runtime_temp_dir();
if (! is_dir($dir)) {
mkdir($dir, 0755, true);
}
$path = $dir . DIRECTORY_SEPARATOR . $name;
file_put_contents($path, $body);
return $path;
}
private function runEnvScenarios($storage): void
{
CLI::write('--- ENV ---', 'white');
$driver = strtolower((string) (getenv('FILE_STORAGE_DRIVER') ?: env('FILE_STORAGE_DRIVER') ?: ''));
$bucket = (string) (getenv('AWS_FILE_UPLOAD_BUCKET') ?: getenv('AWS_BUCKET') ?: '');
$this->step('ENV-01', true, "driver=" . ($driver !== '' ? $driver : 'default') . ", retain=" . (storage_env_retain_local() ? '1' : '0') . ", bucket=" . ($bucket !== '' ? 'set' : 'empty'));
$runtime = storage_runtime_temp_dir();
$claimRt = rtrim(WRITEPATH, '/\\') . '/cache/claim_files_runtime';
foreach ([$runtime, $claimRt] as $d) {
if (! is_dir($d)) {
@mkdir($d, 0755, true);
}
}
$probe = $runtime . DIRECTORY_SEPARATOR . 'probe_' . uniqid('', true) . '.txt';
$wrote = @file_put_contents($probe, 'ok') !== false;
if ($wrote) {
@unlink($probe);
}
$this->step('ENV-02', $wrote && is_dir($runtime));
if ($driver === 'local') {
$this->step('ENV-03', ! $storage->usesS3(), 'local driver => usesS3 false');
} elseif ($driver === 's3' || $driver === '') {
$this->step('ENV-03', $storage->usesS3(), 's3 driver => usesS3 true (bucket required)');
} else {
$this->skip('ENV-03', 'unknown FILE_STORAGE_DRIVER=' . $driver);
}
}
private function runModuleLifecycle($storage, string $mod): void
{
CLI::write("--- MODULE: {$mod} ---", 'white');
$path = $this->uploadPath($mod);
$fileName = 'smoke_' . $mod . '_' . date('Ymd_His') . '_' . bin2hex(random_bytes(2)) . '.txt';
$tmp = $this->makeTmp($fileName, "smoke-{$mod}\n" . date('c') . "\n");
$up = $storage->upload($tmp, $path, $fileName);
@unlink($tmp);
$this->step("MOD-{$mod}-01", (bool) ($up['success'] ?? false), (string) ($up['message'] ?? ''));
if (! ($up['success'] ?? false)) {
// Skip rest of module if upload failed
foreach (['02', '03', '04', '05', '06', '07'] as $n) {
$this->skip("MOD-{$mod}-{$n}", 'upload failed');
}
return;
}
$this->step("MOD-{$mod}-02", $storage->exists($path, $fileName));
$dl = $storage->download($path, $fileName);
$contentOk = ($dl['success'] ?? false) && (
((! empty($dl['content'])) && str_contains((string) $dl['content'], "smoke-{$mod}"))
|| ((! empty($dl['path'])) && is_file((string) $dl['path'])
&& str_contains((string) file_get_contents((string) $dl['path']), "smoke-{$mod}"))
);
$this->step("MOD-{$mod}-03", $contentOk, (string) ($dl['message'] ?? ''));
// Force missing permanent local then ensure_local
$permanent = $storage->resolveLocalPath($path, $fileName);
if (is_file($permanent)) {
@unlink($permanent);
}
$ensured = storage_ensure_local_file($path, $fileName);
$this->step(
"MOD-{$mod}-04",
is_string($ensured) && is_file($ensured) && str_contains((string) file_get_contents($ensured), "smoke-{$mod}"),
$ensured ? basename($ensured) : 'null'
);
if ($ensured) {
storage_cleanup_temp_file($ensured);
}
if ($storage->usesS3() && ! storage_env_retain_local()) {
$this->step("MOD-{$mod}-05", ! is_file($permanent), 'permanent path absent');
} else {
$this->skip("MOD-{$mod}-05", $storage->usesS3() ? 'RETAIN_LOCAL=true' : 'local driver');
}
$del = $storage->delete($path, $fileName);
$this->step("MOD-{$mod}-06", (bool) ($del['success'] ?? false) && ! $storage->exists($path, $fileName));
if ($storage->usesS3()) {
$tmp = $this->makeTmp($fileName, "presign-{$mod}\n");
$storage->upload($tmp, $path, $fileName);
@unlink($tmp);
$pre = $storage->getPresignedUrl($path, $fileName, 5);
$this->step("MOD-{$mod}-07", (bool) ($pre['success'] ?? false) && ! empty($pre['url']));
$storage->delete($path, $fileName);
} else {
$this->skip("MOD-{$mod}-07", 'local driver');
}
}
private function runClaimScenarios($storage): void
{
CLI::write('--- CLAIM ---', 'white');
$claimDir = $this->uploadPath('claim_files');
$fileName = 'smoke_claim_' . date('Ymd_His') . '_' . bin2hex(random_bytes(2)) . '.pdf';
$tmp = $this->makeTmp($fileName, "%PDF-1.4 smoke-claim\n");
$up = $storage->upload($tmp, $claimDir, $fileName);
@unlink($tmp);
if (! ($up['success'] ?? false)) {
$this->skip('CLAIM-01', 'upload claim sample failed');
$this->skip('CLAIM-02', 'upload claim sample failed');
$this->skip('CLAIM-03', 'upload claim sample failed');
return;
}
// Ensure no permanent leftover interferes
$permanent = $storage->resolveLocalPath($claimDir, $fileName);
if (is_file($permanent)) {
@unlink($permanent);
}
$resolved = storage_resolve_claim_file_path($claimDir, $fileName);
$this->step(
'CLAIM-01',
(bool) ($resolved['success'] ?? false) && ! empty($resolved['path']) && is_file((string) $resolved['path']),
(string) ($resolved['message'] ?? '')
);
storage_cleanup_temp_claim_file($resolved);
if ($storage->usesS3()) {
$url = storage_claim_file_download_url($fileName, null, 10);
$this->step('CLAIM-02', is_string($url) && str_starts_with($url, 'http'));
} else {
$this->skip('CLAIM-02', 'local driver');
}
// Soft-delete: insert temp claim_files row, soft delete, expect S3 gone
try {
$db = \Config\Database::connect();
$insert = [
'ticket_master_id' => 0,
'file_name' => $fileName,
'url' => $fileName,
'doc_name' => 'smoke-test',
'file_type' => 2,
'is_active' => 1,
'created_at' => date('Y-m-d H:i:s'),
];
// Only use columns that exist
$fields = $db->getFieldNames('claim_files');
$row = [];
foreach ($insert as $k => $v) {
if (in_array($k, $fields, true)) {
$row[$k] = $v;
}
}
$db->table('claim_files')->insert($row);
$id = (int) $db->insertID();
$ok = $id > 0 && storage_soft_delete_claim_file($id);
$gone = ! $storage->exists($claimDir, $fileName);
$this->step('CLAIM-03', $ok && $gone, 'id=' . $id);
// Hard cleanup leftover row
if ($id > 0) {
$db->table('claim_files')->where('id', $id)->delete();
}
} catch (\Throwable $e) {
$this->skip('CLAIM-03', 'DB soft-delete unavailable: ' . $e->getMessage());
$storage->delete($claimDir, $fileName);
}
}
private function runMailScenarios($storage): void
{
CLI::write('--- MAIL ---', 'white');
$dir = $this->uploadPath('attachments');
$fileName = 'smoke_mail_' . date('Ymd_His') . '_' . bin2hex(random_bytes(2)) . '.txt';
$tmp = $this->makeTmp($fileName, "mail\n");
$up = $storage->upload($tmp, $dir, $fileName);
@unlink($tmp);
if (! ($up['success'] ?? false)) {
$this->step('MAIL-01', false, (string) ($up['message'] ?? ''));
return;
}
$permanent = $storage->resolveLocalPath($dir, $fileName);
if (is_file($permanent)) {
@unlink($permanent);
}
$resolved = storage_resolve_mail_attachment_path('uploads/attachments/' . $fileName, $fileName);
$this->step(
'MAIL-01',
(bool) ($resolved['success'] ?? false) && ! empty($resolved['path']) && is_file((string) $resolved['path'])
);
if (! empty($resolved['path'])) {
storage_cleanup_temp_file((string) $resolved['path']);
}
$storage->delete($dir, $fileName);
}
private function runMirrorScenario($storage): void
{
CLI::write('--- GENERATED ---', 'white');
$dir = $this->uploadPath('excel');
$fileName = 'smoke_gen_' . date('Ymd_His') . '_' . bin2hex(random_bytes(2)) . '.txt';
$local = $this->makeTmp($fileName, "generated\n");
$ok = storage_mirror_generated_file($local, $dir, $fileName);
if ($storage->usesS3()) {
$this->step('GEN-01', $ok && ! is_file($local) && $storage->exists($dir, $fileName));
$storage->delete($dir, $fileName);
} else {
$this->step('GEN-01', $ok && is_file($local));
@unlink($local);
}
}
private function runRetainLocalSimulation($storage): void
{
CLI::write('--- RETAIN_LOCAL simulation ---', 'white');
if (! $storage->usesS3()) {
$this->skip('RETAIN-01', 'local driver');
$this->skip('RETAIN-02', 'local driver');
return;
}
$dir = $this->uploadPath('excel');
// retain=false behaviour: upload via temp then unlink (current default path)
$f1 = 'smoke_retain_off_' . bin2hex(random_bytes(2)) . '.txt';
$t1 = $this->makeTmp($f1, "off\n");
$storage->upload($t1, $dir, $f1);
@unlink($t1);
$p1 = $storage->resolveLocalPath($dir, $f1);
$this->step('RETAIN-01', ! is_file($p1) && $storage->exists($dir, $f1));
$storage->delete($dir, $f1);
// retain=true behaviour: keep local file after S3 upload
$f2 = 'smoke_retain_on_' . bin2hex(random_bytes(2)) . '.txt';
// Use runtime path as the "kept local" copy (CLI may lack write perms under uploads/)
$p2 = $this->makeTmp($f2, "on\n");
$up2 = $storage->upload($p2, $dir, $f2);
$this->step(
'RETAIN-02',
(bool) ($up2['success'] ?? false) && is_file($p2) && $storage->exists($dir, $f2),
'local kept + S3'
);
@unlink($p2);
$storage->delete($dir, $f2);
}
private function runClaimsDumpResolve($storage): void
{
CLI::write('--- CLAIMS_DUMP ---', 'white');
$dir = $this->uploadPath('claims_dump');
$fileName = 'smoke_cdump_' . date('Ymd_His') . '_' . bin2hex(random_bytes(2)) . '.xlsx';
$tmp = $this->makeTmp($fileName, "claims-dump\n");
$up = $storage->upload($tmp, $dir, $fileName);
@unlink($tmp);
if (! ($up['success'] ?? false)) {
$this->step('DUMP-01', false, (string) ($up['message'] ?? ''));
return;
}
$permanent = $storage->resolveLocalPath($dir, $fileName);
if (is_file($permanent)) {
@unlink($permanent);
}
$resolved = storage_ensure_local_file($dir, $fileName);
$this->step('DUMP-01', is_string($resolved) && is_file($resolved));
if ($resolved) {
storage_cleanup_temp_file($resolved);
}
$storage->delete($dir, $fileName);
}
/** @return list<string> */
private function manualScenarioLines(): array
{
return [
'UI-KYC-01 Upload KYC in browser',
'UI-KYC-02 Download download-kyc-docs/{id}',
'UI-KYC-03 Re-upload / delete KYC',
'UI-PT-01 Upload PT doc + download-pt-docs/{id}',
'UI-PT-02 REST downloadPolicyFiles',
'UI-CLAIM-01 Upload claim file in ticket UI',
'UI-CLAIM-02 downloadClaimFile / viewClaimFile',
'UI-CLAIM-03 Remove claim file in UI',
'UI-CLAIM-04 Merge PDFs in UI (core automated: php spark storage:smoke-test-merge-pdf)',
'UI-TPA-01 FHPL claim submit (base64)',
'UI-TPA-02 Vidal claim submit (base64)',
'UI-TPA-03 HealthIndia claim submit',
'UI-TPA-04 MediAssist uses presigned URL',
'UI-TPA-05 Volo uses presigned URL',
'UI-EMP-01 Employee excel upload + background job',
'UI-EMP-02 download-file-list / download_import_file',
'UI-EMP-03 HR upload + hrFileDownload',
'UI-LEAD-01 Lead member upload + demography job',
'UI-LEAD-02 downloadMemberFile / error excel',
'UI-DUMP-01 Claim dump / MIS / statements / BDS UI jobs',
'UI-MAIL-01 Compose mail with attachment send',
'UI-NEB-01 Non-EB asset + rack rate UI',
'UI-NEG-01 Invalid extension rejected in UI',
'UI-NEG-02 Toggle FILE_STORAGE_DRIVER=local and retest one upload',
'UI-NEG-03 Toggle RETAIN_LOCAL=true/false and retest one upload',
];
}
private function printManualOnlyRemaining(): void
{
CLI::write('========== MANUAL UI / TPA / JOB (not auto-runnable) ==========', 'yellow');
CLI::write('These need browser login / real TPA — tick after you test:');
foreach ($this->manualScenarioLines() as $i => $line) {
CLI::write(sprintf(' [ ] %s', $line));
}
CLI::newLine();
CLI::write('Commands:');
CLI::write(' php spark storage:smoke-test # run ALL automated scenarios');
CLI::write(' php spark storage:smoke-test --list # list every scenario ID');
CLI::write(' php spark storage:smoke-test --module claim_files');
}
}