← Code-Übersicht

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