415 lines
14 KiB
PHP
415 lines
14 KiB
PHP
<?php
|
|
|
|
namespace UbsCsvTransformer\Tests;
|
|
|
|
use PHPUnit\Framework\TestCase;
|
|
use UbsCsvTransformer\FireflyImporter;
|
|
use UbsCsvTransformer\DebugLogger;
|
|
|
|
/**
|
|
* Tests for the chunked-import state file / resume feature.
|
|
*
|
|
* Strategy: subclass FireflyImporter and override import() so no real HTTP or
|
|
* CLI call is made. The override is configured per test via a callable queue.
|
|
*/
|
|
class FireflyImporterChunkStateTest extends TestCase
|
|
{
|
|
/** @var string Temporary directory for CSV and state files */
|
|
private string $tmpDir;
|
|
|
|
/** @var string Path to a throwaway JSON config file */
|
|
private string $jsonConfig;
|
|
|
|
protected function setUp(): void
|
|
{
|
|
DebugLogger::reset();
|
|
|
|
$this->tmpDir = sys_get_temp_dir() . '/ffi_state_test_' . uniqid('', true);
|
|
mkdir($this->tmpDir, 0700, true);
|
|
|
|
// Minimal Firefly importer config (format v3)
|
|
$configData = [
|
|
'version' => 3,
|
|
'flow' => 'csv',
|
|
'roles' => ['amount'],
|
|
'default_account' => 1,
|
|
];
|
|
$this->jsonConfig = $this->tmpDir . '/ff-config.json';
|
|
file_put_contents($this->jsonConfig, json_encode($configData));
|
|
}
|
|
|
|
protected function tearDown(): void
|
|
{
|
|
// Remove all temp files
|
|
foreach (glob($this->tmpDir . '/*') ?: [] as $f) {
|
|
@unlink($f);
|
|
}
|
|
@rmdir($this->tmpDir);
|
|
}
|
|
|
|
// ─── Helpers ─────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Creates an importer stub whose import() calls return results from $queue
|
|
* in order. Each element of the queue is either true (success) or false (failure).
|
|
*
|
|
* @param array<bool> $importResultQueue
|
|
* @param int $chunkSize
|
|
*/
|
|
private function makeImporter(array $importResultQueue, int $chunkSize): FireflyImporter
|
|
{
|
|
$config = [
|
|
'mode' => 'http',
|
|
'importerUrl' => 'https://example.com',
|
|
'accessToken' => 'test-secret-1234567',
|
|
'personalSecret' => 'test-pat',
|
|
'jsonConfig' => $this->jsonConfig,
|
|
'chunkSize' => $chunkSize,
|
|
];
|
|
|
|
$queue = $importResultQueue;
|
|
|
|
return new class ($config, $queue) extends FireflyImporter {
|
|
/** @var array<bool> */
|
|
private array $queue;
|
|
|
|
/** @param array<bool> $queue */
|
|
public function __construct(array $config, array $queue)
|
|
{
|
|
parent::__construct($config);
|
|
$this->queue = $queue;
|
|
}
|
|
|
|
public function import(string $csvFile): array
|
|
{
|
|
$success = array_shift($this->queue) ?? true;
|
|
if ($success) {
|
|
return [
|
|
'success' => true,
|
|
'exit_code' => 200,
|
|
'output' => ['stdout' => '', 'stderr' => ''],
|
|
'duration' => 1.0,
|
|
'csv_file' => $csvFile,
|
|
'summary' => [
|
|
'completed' => true,
|
|
'created' => 1,
|
|
'by_type' => ['withdrawal' => 1],
|
|
'duplicates' => 0,
|
|
'errors' => [],
|
|
],
|
|
];
|
|
}
|
|
return [
|
|
'success' => false,
|
|
'error' => 'Simulated failure',
|
|
'output' => ['stdout' => '', 'stderr' => ''],
|
|
'exit_code' => 500,
|
|
];
|
|
}
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Writes a CSV with $dataRows data rows (each row has two columns).
|
|
*/
|
|
private function writeCsv(string $path, int $dataRows): void
|
|
{
|
|
$fp = fopen($path, 'w');
|
|
assert($fp !== false);
|
|
fputcsv($fp, ['col_a', 'col_b'], ',', '"', '\\');
|
|
for ($i = 1; $i <= $dataRows; $i++) {
|
|
fputcsv($fp, ["val_a_{$i}", "val_b_{$i}"], ',', '"', '\\');
|
|
}
|
|
fclose($fp);
|
|
}
|
|
|
|
private function stateFile(string $csvPath): string
|
|
{
|
|
return $csvPath . '.ffi-state.json';
|
|
}
|
|
|
|
// ─── Tests ───────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* When chunkSize is 0, import() is used directly — no state file should appear.
|
|
*/
|
|
public function testNoStateFileWhenChunkingNotUsed(): void
|
|
{
|
|
$csv = $this->tmpDir . '/test.csv';
|
|
$this->writeCsv($csv, 5);
|
|
|
|
$importer = $this->makeImporter([true], 0);
|
|
$result = $importer->importChunked($csv);
|
|
|
|
$this->assertTrue($result['success']);
|
|
$this->assertFileDoesNotExist($this->stateFile($csv));
|
|
}
|
|
|
|
/**
|
|
* When the file has fewer rows than chunkSize, no chunking occurs — no state file.
|
|
*/
|
|
public function testNoStateFileWhenRowsBelowChunkSize(): void
|
|
{
|
|
$csv = $this->tmpDir . '/test.csv';
|
|
$this->writeCsv($csv, 3);
|
|
|
|
$importer = $this->makeImporter([true], 10);
|
|
$importer->importChunked($csv);
|
|
|
|
$this->assertFileDoesNotExist($this->stateFile($csv));
|
|
}
|
|
|
|
/**
|
|
* After chunk 1 of 3 fails, the state file must exist and record 0 completed chunks.
|
|
*/
|
|
public function testStateFileCreatedOnFirstChunkFailure(): void
|
|
{
|
|
$csv = $this->tmpDir . '/test.csv';
|
|
$this->writeCsv($csv, 9); // 3 chunks of 3
|
|
|
|
// Chunk 1 fails immediately
|
|
$importer = $this->makeImporter([false], 3);
|
|
$result = $importer->importChunked($csv);
|
|
|
|
$this->assertFalse($result['success']);
|
|
$this->assertFileExists($this->stateFile($csv));
|
|
|
|
/** @var array<string, mixed> $state */
|
|
$state = json_decode((string) file_get_contents($this->stateFile($csv)), true);
|
|
$this->assertSame([], $state['completed_chunks']);
|
|
}
|
|
|
|
/**
|
|
* After chunks 1 and 2 succeed but chunk 3 fails, the state file records [0, 1].
|
|
*/
|
|
public function testStateFileRecordsCompletedChunksOnPartialFailure(): void
|
|
{
|
|
$csv = $this->tmpDir . '/test.csv';
|
|
$this->writeCsv($csv, 9); // 3 chunks of 3
|
|
|
|
// Chunks 0, 1 succeed; chunk 2 fails
|
|
$importer = $this->makeImporter([true, true, false], 3);
|
|
$result = $importer->importChunked($csv);
|
|
|
|
$this->assertFalse($result['success']);
|
|
$this->assertFileExists($this->stateFile($csv));
|
|
|
|
/** @var array<string, mixed> $state */
|
|
$state = json_decode((string) file_get_contents($this->stateFile($csv)), true);
|
|
$this->assertSame([0, 1], $state['completed_chunks']);
|
|
$this->assertArrayHasKey('0', $state['chunk_results']);
|
|
$this->assertArrayHasKey('1', $state['chunk_results']);
|
|
}
|
|
|
|
/**
|
|
* After full success the state file is deleted automatically.
|
|
*/
|
|
public function testStateFileDeletedAfterFullSuccess(): void
|
|
{
|
|
$csv = $this->tmpDir . '/test.csv';
|
|
$this->writeCsv($csv, 6); // 2 chunks of 3
|
|
|
|
$importer = $this->makeImporter([true, true], 3);
|
|
$result = $importer->importChunked($csv);
|
|
|
|
$this->assertTrue($result['success']);
|
|
$this->assertFileDoesNotExist($this->stateFile($csv));
|
|
}
|
|
|
|
/**
|
|
* On a second run with an existing state showing [0, 1] done, only chunk 2
|
|
* (index 2) should call import() — i.e., exactly one call is made.
|
|
*/
|
|
public function testResumeSkipsAlreadyCompletedChunks(): void
|
|
{
|
|
$csv = $this->tmpDir . '/test.csv';
|
|
$this->writeCsv($csv, 9); // 3 chunks of 3
|
|
|
|
// ── First run: chunks 0+1 succeed, chunk 2 fails ────────────────────
|
|
$run1 = $this->makeImporter([true, true, false], 3);
|
|
$run1->importChunked($csv);
|
|
|
|
$this->assertFileExists($this->stateFile($csv));
|
|
|
|
// ── Second run: only chunk 2 should be attempted ────────────────────
|
|
// We record how many times import() is actually called via a counting wrapper
|
|
$counter = new \stdClass();
|
|
$counter->value = 0;
|
|
|
|
$config = [
|
|
'mode' => 'http',
|
|
'importerUrl' => 'https://example.com',
|
|
'accessToken' => 'test-secret-1234567',
|
|
'personalSecret' => 'test-pat',
|
|
'jsonConfig' => $this->jsonConfig,
|
|
'chunkSize' => 3,
|
|
];
|
|
|
|
$run2 = new class ($config, $counter) extends FireflyImporter {
|
|
private \stdClass $counter;
|
|
|
|
public function __construct(array $config, \stdClass $counter)
|
|
{
|
|
parent::__construct($config);
|
|
$this->counter = $counter;
|
|
}
|
|
|
|
public function import(string $csvFile): array
|
|
{
|
|
$this->counter->value++;
|
|
return [
|
|
'success' => true,
|
|
'exit_code' => 200,
|
|
'output' => ['stdout' => '', 'stderr' => ''],
|
|
'duration' => 1.0,
|
|
'csv_file' => $csvFile,
|
|
'summary' => [
|
|
'completed' => true,
|
|
'created' => 1,
|
|
'by_type' => ['withdrawal' => 1],
|
|
'duplicates' => 0,
|
|
'errors' => [],
|
|
],
|
|
];
|
|
}
|
|
};
|
|
|
|
$result2 = $run2->importChunked($csv);
|
|
|
|
$this->assertTrue($result2['success']);
|
|
$this->assertSame(1, $counter->value, 'Only the 1 remaining chunk (index 2) should be imported');
|
|
$this->assertFileDoesNotExist($this->stateFile($csv), 'State file must be deleted after full success');
|
|
}
|
|
|
|
/**
|
|
* A state file whose total_rows does not match the current CSV is silently
|
|
* discarded and a fresh import is started.
|
|
*/
|
|
public function testStaleMismatchedStateIsIgnored(): void
|
|
{
|
|
$csv = $this->tmpDir . '/test.csv';
|
|
$this->writeCsv($csv, 9); // 3 chunks of 3
|
|
|
|
// Plant a stale state file with a wrong total_rows
|
|
$staleState = [
|
|
'csv_file' => realpath($csv) ?: $csv,
|
|
'total_rows' => 99, // wrong
|
|
'chunk_size' => 3,
|
|
'total_chunks' => 3,
|
|
'completed_chunks' => [0, 1],
|
|
'chunk_results' => [],
|
|
'created_at' => '2020-01-01T00:00:00+00:00',
|
|
'updated_at' => '2020-01-01T00:00:00+00:00',
|
|
];
|
|
file_put_contents($this->stateFile($csv), json_encode($staleState));
|
|
|
|
// All 3 chunks should be called (fresh start despite stale state)
|
|
$counter = new \stdClass();
|
|
$counter->value = 0;
|
|
|
|
$config = [
|
|
'mode' => 'http',
|
|
'importerUrl' => 'https://example.com',
|
|
'accessToken' => 'test-secret-1234567',
|
|
'personalSecret' => 'test-pat',
|
|
'jsonConfig' => $this->jsonConfig,
|
|
'chunkSize' => 3,
|
|
];
|
|
|
|
$importer = new class ($config, $counter) extends FireflyImporter {
|
|
private \stdClass $counter;
|
|
|
|
public function __construct(array $config, \stdClass $counter)
|
|
{
|
|
parent::__construct($config);
|
|
$this->counter = $counter;
|
|
}
|
|
|
|
public function import(string $csvFile): array
|
|
{
|
|
$this->counter->value++;
|
|
return [
|
|
'success' => true,
|
|
'exit_code' => 200,
|
|
'output' => ['stdout' => '', 'stderr' => ''],
|
|
'duration' => 1.0,
|
|
'csv_file' => $csvFile,
|
|
'summary' => [
|
|
'completed' => true,
|
|
'created' => 1,
|
|
'by_type' => ['withdrawal' => 1],
|
|
'duplicates' => 0,
|
|
'errors' => [],
|
|
],
|
|
];
|
|
}
|
|
};
|
|
|
|
$result = $importer->importChunked($csv);
|
|
|
|
$this->assertTrue($result['success']);
|
|
$this->assertSame(3, $counter->value, 'All 3 chunks must be imported when stale state is discarded');
|
|
}
|
|
|
|
/**
|
|
* A corrupt (non-JSON) state file is silently discarded; no exception is thrown.
|
|
*/
|
|
public function testCorruptStateFileIsIgnored(): void
|
|
{
|
|
$csv = $this->tmpDir . '/test.csv';
|
|
$this->writeCsv($csv, 6); // 2 chunks of 3
|
|
|
|
file_put_contents($this->stateFile($csv), '{this is not valid json!!!}');
|
|
|
|
$importer = $this->makeImporter([true, true], 3);
|
|
$result = $importer->importChunked($csv);
|
|
|
|
$this->assertTrue($result['success']);
|
|
}
|
|
|
|
/**
|
|
* resetImportState() deletes an existing state file.
|
|
*/
|
|
public function testResetImportStateClearsStateFile(): void
|
|
{
|
|
$csv = $this->tmpDir . '/test.csv';
|
|
$this->writeCsv($csv, 9);
|
|
|
|
// Plant a state file
|
|
file_put_contents($this->stateFile($csv), '{}');
|
|
$this->assertFileExists($this->stateFile($csv));
|
|
|
|
$importer = $this->makeImporter([], 3);
|
|
$importer->resetImportState($csv);
|
|
|
|
$this->assertFileDoesNotExist($this->stateFile($csv));
|
|
}
|
|
|
|
/**
|
|
* hasResumeState() returns false when no state file is present.
|
|
*/
|
|
public function testHasResumeStateReturnsFalseWithoutStateFile(): void
|
|
{
|
|
$csv = $this->tmpDir . '/test.csv';
|
|
$this->writeCsv($csv, 9);
|
|
|
|
$importer = $this->makeImporter([], 3);
|
|
$this->assertFalse($importer->hasResumeState($csv));
|
|
}
|
|
|
|
/**
|
|
* hasResumeState() returns true after a partial failure creates a valid state file.
|
|
*/
|
|
public function testHasResumeStateReturnsTrueAfterPartialFailure(): void
|
|
{
|
|
$csv = $this->tmpDir . '/test.csv';
|
|
$this->writeCsv($csv, 9); // 3 chunks of 3
|
|
|
|
$importer = $this->makeImporter([true, false], 3); // chunk 2 (index 1) fails
|
|
$importer->importChunked($csv);
|
|
|
|
$importer2 = $this->makeImporter([], 3);
|
|
$this->assertTrue($importer2->hasResumeState($csv));
|
|
}
|
|
}
|