← Code-Übersicht

IngestInvoicesUseCaseTest.php

Pfad: tests/Rechnung/IngestInvoicesUseCaseTest.php

Ext: php

Größe: 7181 Bytes

Geändert: 2026-07-16T17:45:45+02:00

Frühere Version vom 2026-07-16T17:45:45+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);
    }
}

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
{
    /** @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 { 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'; }
}