IngestInvoicesUseCaseTest.php
Pfad: tests/Rechnung/IngestInvoicesUseCaseTest.php
Ext: php
Größe: 8184 Bytes
Geändert: 2026-07-16 18:16:36+02
<?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): \Demo\Domain\Rechnung\WetellSessionHandle
{
return \Demo\Domain\Rechnung\WetellSessionHandle::issue($this);
}
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';
}
}
Frühere Versionen
| Version | Zeitpunkt | Operation |
|---|---|---|
| 57 | 2026-07-16 20:18:22.873738+02 | UPDATE |
| 50 | 2026-07-16 20:03:52.978699+02 | UPDATE |