IngestInvoicesUseCaseTest.php
Pfad: tests/Rechnung/IngestInvoicesUseCaseTest.php
Ext: php
Größe: 7942 Bytes
Geändert: 2026-07-16T18:00:15+02:00
Frühere Version vom 2026-07-16T18:00:15+02:00 · zur aktuellen Fassung
<?php
declare(strict_types=1);
namespace Demo\Tests\Rechnung;
use Demo\Application\Rechnung\DiscoverInvoicesUseCase;
use Demo\Application\Rechnung\DownloadInvoiceUseCase;
use Demo\Application\Rechnung\ExtractInvoiceDataUseCase;
use Demo\Application\Rechnung\IngestInvoicesUseCase;
use Demo\Application\Rechnung\PostInvoiceUseCase;
use Demo\Application\Rechnung\ResolveVendorUseCase;
use Demo\Domain\Rechnung\Amount;
use Demo\Domain\Rechnung\ClockPort;
use Demo\Domain\Rechnung\ExtractedInvoiceData;
use Demo\Domain\Rechnung\InvoiceFileStoragePort;
use Demo\Domain\Rechnung\InvoiceLoggerPort;
use Demo\Domain\Rechnung\InvoiceState;
use Demo\Domain\Rechnung\InvoiceStatePort;
use Demo\Domain\Rechnung\LexofficePort;
use Demo\Domain\Rechnung\PostingResult;
use Demo\Domain\Rechnung\SourceInvoice;
use Demo\Domain\Rechnung\TextExtractionPort;
use Demo\Domain\Rechnung\WetellSourcePort;
use PHPUnit\Framework\TestCase;
use RuntimeException;
final class IngestInvoicesUseCaseTest extends TestCase
{
public function testLocksRunAndSkipsSameInvoiceAtDifferentUrl(): void
{
$fixture = new InvoicePipelineFixture();
$first = $fixture->application()->execute('run-1', 'user', 'pass');
$fixture->portal->sources = [$fixture->source('source-b', 'b.pdf', 'RF123')];
$second = $fixture->application()->execute('run-2', 'user', 'pass');
self::assertSame(1, $first->toArray()['processed']);
self::assertSame(1, $second->toArray()['skipped']);
self::assertSame(2, $fixture->state->lockCalls);
self::assertSame(1, $fixture->lexware->remoteCreates);
self::assertSame(1, $fixture->lexware->postCalls);
}
public function testRecoversAfterStateFailureWithoutSecondRemoteVoucher(): void
{
$fixture = new InvoicePipelineFixture();
$fixture->state->failOnSave = 4;
$failed = $fixture->application()->execute('run-fail', 'user', 'pass');
$recovered = $fixture->application()->execute('run-recover', 'user', 'pass');
self::assertSame(1, $failed->toArray()['errors']);
self::assertSame(1, $recovered->toArray()['processed']);
self::assertSame(1, $fixture->lexware->remoteCreates);
self::assertSame(2, $fixture->lexware->postCalls);
}
public function testClassifiesUnexpectedDownloadFailureAtItsOrigin(): void
{
$fixture = new InvoicePipelineFixture();
$fixture->portal->failDownload = true;
$result = $fixture->application()->execute('run-download-error', 'user', 'pass');
$records = $fixture->state->state->toArray()['records'];
self::assertSame(1, $result->toArray()['errors']);
self::assertSame('ERROR_DOWNLOAD', $records[0]['status']);
self::assertSame('DOWNLOAD', $records[0]['errors'][0]['code']);
self::assertSame('download', $records[0]['errors'][0]['step']);
}
}
final class InvoicePipelineFixture
{
public FakeInvoiceStatePort $state;
public FakeWetellPort $portal;
public IdempotentLexwarePort $lexware;
private FakeInvoiceLogger $logger;
private FixedClock $clock;
public function __construct()
{
$this->state = new FakeInvoiceStatePort();
$this->portal = new FakeWetellPort([$this->source('source-a', 'a.pdf', 'RF123')]);
$this->lexware = new IdempotentLexwarePort();
$this->logger = new FakeInvoiceLogger();
$this->clock = new FixedClock();
}
public function source(string $id, string $file, ?string $number): SourceInvoice
{
return new SourceInvoice($id, 'WEtell GmbH', 'https://mein.wetell.de/' . $file, '2026-07-04', $number);
}
public function application(): IngestInvoicesUseCase
{
$storage = new MemoryInvoiceStorage();
$extractor = new FixedTextExtractor();
return new IngestInvoicesUseCase(
$this->state,
new DiscoverInvoicesUseCase($this->portal, $this->logger),
new DownloadInvoiceUseCase($this->portal, $storage, $this->logger, $this->clock),
new ExtractInvoiceDataUseCase($extractor, $this->logger, $this->clock),
new ResolveVendorUseCase($this->lexware, $this->logger),
new PostInvoiceUseCase($this->lexware, $this->logger),
$this->logger,
$this->clock,
1,
0.01,
);
}
}
final class FakeInvoiceStatePort implements InvoiceStatePort
{
public InvoiceState $state;
public int $lockCalls = 0;
public int $saveCalls = 0;
public ?int $failOnSave = null;
public function __construct() { $this->state = InvoiceState::empty(); }
public function load(): InvoiceState { return $this->state; }
public function save(InvoiceState $state): void
{
$this->saveCalls++;
if ($this->failOnSave === $this->saveCalls) { $this->failOnSave = null; throw new RuntimeException('injected state failure'); }
$this->state = $state;
}
public function withExclusiveLock(callable $operation): mixed { $this->lockCalls++; return $operation(); }
}
final class FakeWetellPort implements WetellSourcePort
{
public bool $failDownload = false;
/** @param list<SourceInvoice> $sources */
public function __construct(public array $sources) {}
public function login(string $username, string $password): void {}
public function discoverInvoices(): array { return $this->sources; }
public function downloadInvoicePdf(string $downloadUrl): string
{
if ($this->failDownload) { throw new RuntimeException('injected portal failure'); }
return "%PDF-1.4\n" . str_repeat('same invoice ', 1000) . "\n%%EOF";
}
}
final class MemoryInvoiceStorage implements InvoiceFileStoragePort
{
public function buildTargetPath(?string $invoiceDateHint, string $vendorName, string $sourceId): string { return sys_get_temp_dir() . '/invoice-fixture-' . getmypid() . '-' . $sourceId . '.pdf'; }
public function persistDownloadedPdf(string $pdfBinary, string $targetPath): void { file_put_contents($targetPath, $pdfBinary); }
public function sha256(string $path): string { $hash = hash_file('sha256', $path); return $hash === false ? '' : $hash; }
}
final class FixedTextExtractor implements TextExtractionPort
{
public function extract(string $pdfPath): ExtractedInvoiceData
{
return new ExtractedInvoiceData('WEtell GmbH', 'Street 1|79106 Freiburg|DE', 'RF123', '2026-07-04', new Amount('26.0504', '4.9496', '31.0000', 'EUR'), 0.99);
}
}
final class IdempotentLexwarePort implements LexofficePort
{
public int $remoteCreates = 0;
public int $postCalls = 0;
private bool $contactExists = false;
/** @var array<string,string> */ private array $vouchers = [];
public function findContactByName(string $vendorName): array
{
return $this->contactExists ? [['id' => 'contact-1', 'roles' => ['vendor' => []], 'company' => ['name' => $vendorName], 'addresses' => ['billing' => [['street' => 'Street 1', 'zip' => '79106', 'city' => 'Freiburg', 'countryCode' => 'DE']]]]] : [];
}
public function createContact(string $vendorName, string $vendorAddressSignature): string { $this->contactExists = true; return 'contact-1'; }
public function postInvoice(ExtractedInvoiceData $invoice, string $contactId, string $pdfPath): PostingResult
{
$this->postCalls++;
$number = (string) $invoice->toArray()['invoiceNumber'];
if (!isset($this->vouchers[$number])) { $this->remoteCreates++; $this->vouchers[$number] = 'voucher-1'; }
return new PostingResult($this->vouchers[$number], 'OPEN', 'file-1');
}
}
final class FakeInvoiceLogger implements InvoiceLoggerPort
{
public function logStep(string $runId, string $step, string $status, array $context = []): void {}
}
final class FixedClock implements ClockPort
{
public function now(): string { return '2026-07-16T12:00:00+00:00'; }
}