additional, improved transformers / skipIf to eliminate records
This commit is contained in:
@@ -435,6 +435,90 @@ class ColumnTransformerTest extends TestCase
|
||||
$this->assertSame('Hello World', $result['B']);
|
||||
}
|
||||
|
||||
public function testOutputActionAppendWithDelimiter(): void
|
||||
{
|
||||
$result = $this->applyOne([
|
||||
'sourceColumn' => 'A',
|
||||
'outputColumn' => 'B',
|
||||
'type' => 'map',
|
||||
'outputAction' => 'append',
|
||||
'appendDelimiter' => ', ',
|
||||
], ['A' => 'World', 'B' => 'Hello']);
|
||||
$this->assertSame('Hello, World', $result['B']);
|
||||
}
|
||||
|
||||
public function testOutputActionAppendWithDelimiterSkippedWhenTargetEmpty(): void
|
||||
{
|
||||
$result = $this->applyOne([
|
||||
'sourceColumn' => 'A',
|
||||
'outputColumn' => 'B',
|
||||
'type' => 'map',
|
||||
'outputAction' => 'append',
|
||||
'appendDelimiter' => ', ',
|
||||
], ['A' => 'Hello', 'B' => '']);
|
||||
$this->assertSame('Hello', $result['B']);
|
||||
}
|
||||
|
||||
public function testOutputActionAppendLine(): void
|
||||
{
|
||||
$result = $this->applyOne([
|
||||
'sourceColumn' => 'A',
|
||||
'outputColumn' => 'B',
|
||||
'type' => 'map',
|
||||
'outputAction' => 'append-line',
|
||||
], ['A' => 'Line2', 'B' => 'Line1']);
|
||||
$this->assertSame("Line1\nLine2", $result['B']);
|
||||
}
|
||||
|
||||
public function testOutputActionAppendLineNoLeadingNewlineWhenEmpty(): void
|
||||
{
|
||||
$result = $this->applyOne([
|
||||
'sourceColumn' => 'A',
|
||||
'outputColumn' => 'B',
|
||||
'type' => 'map',
|
||||
'outputAction' => 'append-line',
|
||||
], ['A' => 'Line1', 'B' => '']);
|
||||
$this->assertSame('Line1', $result['B']);
|
||||
}
|
||||
|
||||
public function testOutputActionOverwriteIfEmpty(): void
|
||||
{
|
||||
$resultEmpty = $this->applyOne([
|
||||
'sourceColumn' => 'A',
|
||||
'outputColumn' => 'B',
|
||||
'type' => 'map',
|
||||
'outputAction' => 'overwrite-if-empty',
|
||||
], ['A' => 'new', 'B' => '']);
|
||||
$this->assertSame('new', $resultEmpty['B']);
|
||||
|
||||
$resultFilled = $this->applyOne([
|
||||
'sourceColumn' => 'A',
|
||||
'outputColumn' => 'B',
|
||||
'type' => 'map',
|
||||
'outputAction' => 'overwrite-if-empty',
|
||||
], ['A' => 'new', 'B' => 'existing']);
|
||||
$this->assertSame('existing', $resultFilled['B']);
|
||||
}
|
||||
|
||||
public function testOutputActionOverwriteIfNotEmpty(): void
|
||||
{
|
||||
$resultNotEmpty = $this->applyOne([
|
||||
'sourceColumn' => 'A',
|
||||
'outputColumn' => 'B',
|
||||
'type' => 'map',
|
||||
'outputAction' => 'overwrite-if-not-empty',
|
||||
], ['A' => 'new', 'B' => 'old']);
|
||||
$this->assertSame('new', $resultNotEmpty['B']);
|
||||
|
||||
$resultEmpty = $this->applyOne([
|
||||
'sourceColumn' => 'A',
|
||||
'outputColumn' => 'B',
|
||||
'type' => 'map',
|
||||
'outputAction' => 'overwrite-if-not-empty',
|
||||
], ['A' => '', 'B' => 'old']);
|
||||
$this->assertSame('old', $resultEmpty['B']);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// multi-output split
|
||||
// -------------------------------------------------------------------------
|
||||
@@ -504,4 +588,139 @@ class ColumnTransformerTest extends TestCase
|
||||
$transformer->transformRow(['A' => '1', 'B' => '2', 'C' => '3']);
|
||||
$this->assertSame(2, $transformer->getOutputColumns());
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// timeperiod
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/** @var array<int, array<string, string>> */
|
||||
private array $testPeriods = [
|
||||
['from' => '04:00:00', 'to' => '08:59:59', 'label' => 'Morgen'],
|
||||
['from' => '09:00:00', 'to' => '10:59:59', 'label' => 'Vormittag'],
|
||||
['from' => '11:00:00', 'to' => '13:59:59', 'label' => 'Mittag'],
|
||||
['from' => '14:00:00', 'to' => '17:59:59', 'label' => 'Nachmittag'],
|
||||
['from' => '18:00:00', 'to' => '21:59:59', 'label' => 'Abend'],
|
||||
['from' => '22:00:00', 'to' => '03:59:59', 'label' => 'Nacht'],
|
||||
];
|
||||
|
||||
public function testTimePeriodBasicMapping(): void
|
||||
{
|
||||
$result = $this->applyOne([
|
||||
'sourceColumn' => 'Time',
|
||||
'outputColumn' => 'Period',
|
||||
'type' => 'timeperiod',
|
||||
'timeFormat' => 'H:i:s',
|
||||
'periods' => $this->testPeriods,
|
||||
'default' => '',
|
||||
], ['Time' => '09:30:00', 'Period' => '']);
|
||||
$this->assertSame('Vormittag', $result['Period']);
|
||||
}
|
||||
|
||||
public function testTimePeriodMidnightSpanning(): void
|
||||
{
|
||||
$result1 = $this->applyOne([
|
||||
'sourceColumn' => 'Time',
|
||||
'outputColumn' => 'Period',
|
||||
'type' => 'timeperiod',
|
||||
'timeFormat' => 'H:i:s',
|
||||
'periods' => $this->testPeriods,
|
||||
'default' => '',
|
||||
], ['Time' => '23:00:00', 'Period' => '']);
|
||||
$this->assertSame('Nacht', $result1['Period']);
|
||||
|
||||
$result2 = $this->applyOne([
|
||||
'sourceColumn' => 'Time',
|
||||
'outputColumn' => 'Period',
|
||||
'type' => 'timeperiod',
|
||||
'timeFormat' => 'H:i:s',
|
||||
'periods' => $this->testPeriods,
|
||||
'default' => '',
|
||||
], ['Time' => '02:00:00', 'Period' => '']);
|
||||
$this->assertSame('Nacht', $result2['Period']);
|
||||
}
|
||||
|
||||
public function testTimePeriodNoMatch(): void
|
||||
{
|
||||
// 03:45 falls outside all labelled ranges except Nacht (00:00-03:59)
|
||||
$result = $this->applyOne([
|
||||
'sourceColumn' => 'Time',
|
||||
'outputColumn' => 'Period',
|
||||
'type' => 'timeperiod',
|
||||
'timeFormat' => 'H:i:s',
|
||||
'periods' => [
|
||||
['from' => '09:00:00', 'to' => '17:59:59', 'label' => 'Day'],
|
||||
],
|
||||
'default' => 'Unknown',
|
||||
], ['Time' => '03:45:00', 'Period' => '']);
|
||||
$this->assertSame('Unknown', $result['Period']);
|
||||
}
|
||||
|
||||
public function testTimePeriodInvalidInput(): void
|
||||
{
|
||||
$result = $this->applyOne([
|
||||
'sourceColumn' => 'Time',
|
||||
'outputColumn' => 'Period',
|
||||
'type' => 'timeperiod',
|
||||
'timeFormat' => 'H:i:s',
|
||||
'periods' => $this->testPeriods,
|
||||
'default' => 'N/A',
|
||||
], ['Time' => '', 'Period' => '']);
|
||||
$this->assertSame('N/A', $result['Period']);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// ucwordsfirst guard
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
public function testUcwordsFirstSkipsLowercase(): void
|
||||
{
|
||||
// Input already contains lowercase letters → must be returned unchanged
|
||||
$result = $this->applyOne([
|
||||
'sourceColumn' => 'A',
|
||||
'outputColumn' => 'A',
|
||||
'type' => 'ucwordsfirst',
|
||||
], ['A' => 'Coop pronto chur']);
|
||||
$this->assertSame('Coop pronto chur', $result['A']);
|
||||
}
|
||||
|
||||
public function testUcwordsFirstAppliesAllCaps(): void
|
||||
{
|
||||
// Fully uppercase input → capitalise first letter of each word
|
||||
$result = $this->applyOne([
|
||||
'sourceColumn' => 'A',
|
||||
'outputColumn' => 'A',
|
||||
'type' => 'ucwordsfirst',
|
||||
], ['A' => 'COOP PRONTO']);
|
||||
$this->assertSame('Coop Pronto', $result['A']);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// append-if-not-empty
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
public function testAppendIfNotEmptySkipsEmpty(): void
|
||||
{
|
||||
// Result is empty → target column must remain unchanged
|
||||
$result = $this->applyOne([
|
||||
'sourceColumn' => 'A',
|
||||
'outputColumn' => 'B',
|
||||
'type' => 'map',
|
||||
'outputAction' => 'append-if-not-empty',
|
||||
'appendDelimiter' => ' ',
|
||||
], ['A' => '', 'B' => 'existing']);
|
||||
$this->assertSame('existing', $result['B']);
|
||||
}
|
||||
|
||||
public function testAppendIfNotEmptyAppendsNonEmpty(): void
|
||||
{
|
||||
// Non-empty result → appended with delimiter
|
||||
$result = $this->applyOne([
|
||||
'sourceColumn' => 'A',
|
||||
'outputColumn' => 'B',
|
||||
'type' => 'map',
|
||||
'outputAction' => 'append-if-not-empty',
|
||||
'appendDelimiter' => ' ',
|
||||
], ['A' => 'new', 'B' => 'existing']);
|
||||
$this->assertSame('existing new', $result['B']);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,414 @@
|
||||
<?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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
<?php
|
||||
|
||||
namespace UbsCsvTransformer\Tests;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use UbsCsvTransformer\RowFilter;
|
||||
|
||||
class RowFilterTest extends TestCase
|
||||
{
|
||||
// -------------------------------------------------------------------------
|
||||
// Leaf-condition operators
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
public function testEmptyOperator(): void
|
||||
{
|
||||
$this->assertTrue(RowFilter::evaluate(
|
||||
['column' => 'A', 'operator' => 'empty'],
|
||||
['A' => '']
|
||||
));
|
||||
$this->assertFalse(RowFilter::evaluate(
|
||||
['column' => 'A', 'operator' => 'empty'],
|
||||
['A' => 'something']
|
||||
));
|
||||
}
|
||||
|
||||
public function testNotEmptyOperator(): void
|
||||
{
|
||||
$this->assertTrue(RowFilter::evaluate(
|
||||
['column' => 'A', 'operator' => 'not-empty'],
|
||||
['A' => 'value']
|
||||
));
|
||||
$this->assertFalse(RowFilter::evaluate(
|
||||
['column' => 'A', 'operator' => 'not-empty'],
|
||||
['A' => '']
|
||||
));
|
||||
}
|
||||
|
||||
public function testEqualsOperator(): void
|
||||
{
|
||||
$this->assertTrue(RowFilter::evaluate(
|
||||
['column' => 'A', 'operator' => 'equals', 'value' => 'hello'],
|
||||
['A' => 'hello']
|
||||
));
|
||||
$this->assertFalse(RowFilter::evaluate(
|
||||
['column' => 'A', 'operator' => 'equals', 'value' => 'hello'],
|
||||
['A' => 'world']
|
||||
));
|
||||
}
|
||||
|
||||
public function testNotEqualsOperator(): void
|
||||
{
|
||||
$this->assertTrue(RowFilter::evaluate(
|
||||
['column' => 'A', 'operator' => 'not-equals', 'value' => 'hello'],
|
||||
['A' => 'world']
|
||||
));
|
||||
$this->assertFalse(RowFilter::evaluate(
|
||||
['column' => 'A', 'operator' => 'not-equals', 'value' => 'hello'],
|
||||
['A' => 'hello']
|
||||
));
|
||||
}
|
||||
|
||||
public function testContainsOperator(): void
|
||||
{
|
||||
$this->assertTrue(RowFilter::evaluate(
|
||||
['column' => 'A', 'operator' => 'contains', 'value' => 'foo'],
|
||||
['A' => 'foobar']
|
||||
));
|
||||
$this->assertFalse(RowFilter::evaluate(
|
||||
['column' => 'A', 'operator' => 'contains', 'value' => 'baz'],
|
||||
['A' => 'foobar']
|
||||
));
|
||||
}
|
||||
|
||||
public function testNotContainsOperator(): void
|
||||
{
|
||||
$this->assertTrue(RowFilter::evaluate(
|
||||
['column' => 'A', 'operator' => 'not-contains', 'value' => 'baz'],
|
||||
['A' => 'foobar']
|
||||
));
|
||||
$this->assertFalse(RowFilter::evaluate(
|
||||
['column' => 'A', 'operator' => 'not-contains', 'value' => 'foo'],
|
||||
['A' => 'foobar']
|
||||
));
|
||||
}
|
||||
|
||||
public function testMatchesOperator(): void
|
||||
{
|
||||
$this->assertTrue(RowFilter::evaluate(
|
||||
['column' => 'A', 'operator' => 'matches', 'pattern' => '^\d{4}$'],
|
||||
['A' => '1234']
|
||||
));
|
||||
$this->assertFalse(RowFilter::evaluate(
|
||||
['column' => 'A', 'operator' => 'matches', 'pattern' => '^\d{4}$'],
|
||||
['A' => 'abcd']
|
||||
));
|
||||
}
|
||||
|
||||
public function testNotMatchesOperator(): void
|
||||
{
|
||||
$this->assertTrue(RowFilter::evaluate(
|
||||
['column' => 'A', 'operator' => 'not-matches', 'pattern' => '^\d{4}$'],
|
||||
['A' => 'abcd']
|
||||
));
|
||||
$this->assertFalse(RowFilter::evaluate(
|
||||
['column' => 'A', 'operator' => 'not-matches', 'pattern' => '^\d{4}$'],
|
||||
['A' => '1234']
|
||||
));
|
||||
}
|
||||
|
||||
public function testGtOperator(): void
|
||||
{
|
||||
$this->assertTrue(RowFilter::evaluate(
|
||||
['column' => 'Amount', 'operator' => 'gt', 'value' => '100'],
|
||||
['Amount' => '150.50']
|
||||
));
|
||||
$this->assertFalse(RowFilter::evaluate(
|
||||
['column' => 'Amount', 'operator' => 'gt', 'value' => '100'],
|
||||
['Amount' => '50']
|
||||
));
|
||||
}
|
||||
|
||||
public function testGteOperator(): void
|
||||
{
|
||||
$this->assertTrue(RowFilter::evaluate(
|
||||
['column' => 'Amount', 'operator' => 'gte', 'value' => '100'],
|
||||
['Amount' => '100']
|
||||
));
|
||||
$this->assertFalse(RowFilter::evaluate(
|
||||
['column' => 'Amount', 'operator' => 'gte', 'value' => '100'],
|
||||
['Amount' => '99.99']
|
||||
));
|
||||
}
|
||||
|
||||
public function testLtOperator(): void
|
||||
{
|
||||
$this->assertTrue(RowFilter::evaluate(
|
||||
['column' => 'Amount', 'operator' => 'lt', 'value' => '100'],
|
||||
['Amount' => '50']
|
||||
));
|
||||
$this->assertFalse(RowFilter::evaluate(
|
||||
['column' => 'Amount', 'operator' => 'lt', 'value' => '100'],
|
||||
['Amount' => '200']
|
||||
));
|
||||
}
|
||||
|
||||
public function testLteOperator(): void
|
||||
{
|
||||
$this->assertTrue(RowFilter::evaluate(
|
||||
['column' => 'Amount', 'operator' => 'lte', 'value' => '100'],
|
||||
['Amount' => '100']
|
||||
));
|
||||
$this->assertFalse(RowFilter::evaluate(
|
||||
['column' => 'Amount', 'operator' => 'lte', 'value' => '100'],
|
||||
['Amount' => '100.01']
|
||||
));
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Groups
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
public function testAndGroupBothTrue(): void
|
||||
{
|
||||
$this->assertTrue(RowFilter::evaluate([
|
||||
'and' => [
|
||||
['column' => 'A', 'operator' => 'empty'],
|
||||
['column' => 'B', 'operator' => 'empty'],
|
||||
],
|
||||
], ['A' => '', 'B' => '']));
|
||||
}
|
||||
|
||||
public function testAndGroupOneFalse(): void
|
||||
{
|
||||
$this->assertFalse(RowFilter::evaluate([
|
||||
'and' => [
|
||||
['column' => 'A', 'operator' => 'empty'],
|
||||
['column' => 'B', 'operator' => 'empty'],
|
||||
],
|
||||
], ['A' => '', 'B' => 'not-empty']));
|
||||
}
|
||||
|
||||
public function testOrGroupOneTrue(): void
|
||||
{
|
||||
$this->assertTrue(RowFilter::evaluate([
|
||||
'or' => [
|
||||
['column' => 'A', 'operator' => 'equals', 'value' => 'yes'],
|
||||
['column' => 'B', 'operator' => 'equals', 'value' => 'yes'],
|
||||
],
|
||||
], ['A' => 'no', 'B' => 'yes']));
|
||||
}
|
||||
|
||||
public function testOrGroupBothFalse(): void
|
||||
{
|
||||
$this->assertFalse(RowFilter::evaluate([
|
||||
'or' => [
|
||||
['column' => 'A', 'operator' => 'equals', 'value' => 'yes'],
|
||||
['column' => 'B', 'operator' => 'equals', 'value' => 'yes'],
|
||||
],
|
||||
], ['A' => 'no', 'B' => 'no']));
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Nested groups
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
public function testNestedAndOrGroup(): void
|
||||
{
|
||||
// (A is empty) AND (B equals "foo" OR C not-empty)
|
||||
$node = [
|
||||
'and' => [
|
||||
['column' => 'A', 'operator' => 'empty'],
|
||||
[
|
||||
'or' => [
|
||||
['column' => 'B', 'operator' => 'equals', 'value' => 'foo'],
|
||||
['column' => 'C', 'operator' => 'not-empty'],
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
// A empty, B matches → true
|
||||
$this->assertTrue(RowFilter::evaluate($node, ['A' => '', 'B' => 'foo', 'C' => '']));
|
||||
// A empty, C not-empty → true
|
||||
$this->assertTrue(RowFilter::evaluate($node, ['A' => '', 'B' => 'bar', 'C' => 'value']));
|
||||
// A empty, but neither B nor C match → false
|
||||
$this->assertFalse(RowFilter::evaluate($node, ['A' => '', 'B' => 'bar', 'C' => '']));
|
||||
// A not empty → false
|
||||
$this->assertFalse(RowFilter::evaluate($node, ['A' => 'x', 'B' => 'foo', 'C' => '']));
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Unknown operator
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
public function testUnknownOperatorThrows(): void
|
||||
{
|
||||
$this->expectException(\InvalidArgumentException::class);
|
||||
RowFilter::evaluate(
|
||||
['column' => 'A', 'operator' => 'nonexistent'],
|
||||
['A' => 'value']
|
||||
);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Missing column (treats as empty string)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
public function testMissingColumnTreatedAsEmpty(): void
|
||||
{
|
||||
$this->assertTrue(RowFilter::evaluate(
|
||||
['column' => 'NonExistent', 'operator' => 'empty'],
|
||||
['A' => 'something']
|
||||
));
|
||||
}
|
||||
}
|
||||
+14
-17
@@ -1,17 +1,14 @@
|
||||
Belastung,Gutschrift,date,process_date,opposing_name,tags,description,opposing_account,notes,account_iban,account_currency
|
||||
-600.00,,2022-12-30,2022-12-30,"David Peter Reindl",Dauerauftrag,"Steuerrueckstellung
|
||||
David Peter Reindl;8906 Bonstetten","CH37 0026 7267 9314 35M2 P","9967864LK2659211
|
||||
8906 Bonstetten","CH18 0026 7267 9314 3540 D",CHF
|
||||
-46.35,,2022-12-30,2022-12-31,"UBS AG",,"Periode: 2022-10-01 - 2022-12-30
|
||||
Zinsabschluss",,9900365AP6356307,"CH18 0026 7267 9314 3540 D",CHF
|
||||
-39.90,,2022-12-30,2022-12-30,"Swisscom Grossunternehme",TWINT,"Swisscom Grossunternehme; Zahlung UBS TWINT",,"9967364GK5707142
|
||||
8004 Zuerich","CH18 0026 7267 9314 3540 D",CHF
|
||||
-8.75,,2022-12-28,2022-12-27,"Coop Pronto Chur",Debitkarte,"18279748-0 08/24
|
||||
Coop Pronto Chur;7007 Chur",,"9930862BN7826808
|
||||
7007 Chur","CH18 0026 7267 9314 3540 D",CHF
|
||||
-1800.00,,2022-12-27,2022-12-27,"Janine Geigele",e-banking,"Skiferien Dolomiten
|
||||
Janine Geigele;Am Wasser 36; 8049 Zuerich; CH","CH63 0023 2232 5560 5988 0","9967361TI3188436
|
||||
8049 Zuerich","CH18 0026 7267 9314 3540 D",CHF
|
||||
,9.00,2022-12-22,2022-12-22,"Friis, Daniela Silvia",TWINT,"Friis, Daniela Silvia",,9930356GK0440989,"CH18 0026 7267 9314 3540 D",CHF
|
||||
,19764.80,2022-11-25,2022-11-25,SBB,Gutschrift,"SBB;Corporate Treasury",,9901820E67741531,"CH18 0026 7267 9314 3540 D",CHF
|
||||
-14.00,,2022-08-22,2022-08-21,"Friis-Loop, Daniela",TWINT,"Friis-Loop, Daniela; Belastung UBS TWINT",,9967233GK1553933,"CH18 0026 7267 9314 3540 D",CHF
|
||||
Belastung,Gutschrift,date,process_date,tags,opposing_iban,opposing_account,opposing_name,notes,description,account_iban,account_currency
|
||||
-600.00,,2022-12-30,2022-12-30,Dauerauftrag,"CH37 0026 7267 9314 35M2 P",,"David Peter Reindl","8906 Bonstetten
|
||||
9967864LK2659211","David Peter Reindl;8906 Bonstetten; STEUERRUECKSTELLUNG; Dauerauftrag","CH18 0026 7267 9314 3540 D",CHF
|
||||
-46.35,,2022-12-30,2022-12-31,,,,"UBS AG",9900365AP6356307,"Saldo Zinsabschluss; Periode: 2022-10-01 - 2022-12-30","CH18 0026 7267 9314 3540 D",CHF
|
||||
-39.90,,2022-12-30,2022-12-30,TWINT,,,"Swisscom Grossunternehme","Muellerstrasse 16 8004 Zuerich TWINT-Acc.:+41796305690
|
||||
9967364GK5707142","SWISSCOM GROSSUNTERNEHME; Zahlung UBS TWINT; Muellerstrasse 16 na, 8004 Zuerich TWINT-Acc.:+41796305690","CH18 0026 7267 9314 3540 D",CHF
|
||||
-8.75,,2022-12-28,2022-12-27,"Abend Debitkarte",,,"Coop Pronto Chur","18279748-0 08/24
|
||||
7007 Chur
|
||||
9930862BN7826808","Coop Pronto Chur;7007 Chur; Zahlung Debitkarte","CH18 0026 7267 9314 3540 D",CHF
|
||||
-1800.00,,2022-12-27,2022-12-27,e-banking,"CH63 0023 2232 5560 5988 0",,"Janine Geigele","8049 Zuerich
|
||||
9967361TI3188436","Janine Geigele;Am Wasser 36; 8049 Zuerich; CH; SKIFERIEN DOLOMITEN; e-banking-Vergütungsauftrag; Wohnung Dolomiten, 2 Personen","CH18 0026 7267 9314 3540 D",CHF
|
||||
,9.00,2022-12-22,2022-12-22,TWINT,,,"Friis, Daniela Silvia",9930356GK0440989,"Friis, Daniela Silvia; Gutschrift UBS TWINT; +41796741245; TWINT-Acc.:+41796305690","CH18 0026 7267 9314 3540 D",CHF
|
||||
,19764.80,2022-11-25,2022-11-25,Gutschrift,,,SBB,9901820E67741531,"SBB;Corporate Treasury; Gutschrift; Lohn/Gehalt 00229537/202211","CH18 0026 7267 9314 3540 D",CHF
|
||||
-14.00,,2022-08-22,2022-08-21,TWINT,,,"Friis-Loop, Daniela",9967233GK1553933,"FRIIS-LOOP, DANIELA; Belastung UBS TWINT; +41796741245; TWINT-Acc.:+41796305690","CH18 0026 7267 9314 3540 D",CHF
|
||||
|
||||
|
Reference in New Issue
Block a user