CluPilotCloud/tests/Feature/Billing/InvoiceArchiveTest.php

147 lines
5.1 KiB
PHP

<?php
use App\Jobs\ArchiveInvoice;
use App\Models\Customer;
use App\Models\Invoice;
use App\Models\Order;
use App\Services\Billing\InvoiceArchive;
use App\Services\Billing\IssueInvoice;
use App\Support\CompanyProfile;
use App\Support\Settings;
use Illuminate\Support\Facades\Queue;
/**
* A copy of every invoice on a filesystem outside this machine.
*
* Nothing here knows about NFS, and that is the point: the application writes
* to a directory, and whether that is a local disk, a bind mount or a NAS over
* NFS 4.1 belongs to the mount. Which is also what makes it testable — these
* run against a temporary directory and prove the same code path.
*/
beforeEach(function () {
CompanyProfile::put([
'name' => 'CluPilot Cloud e.U.', 'address' => 'Dreherstraße 66/1/8',
'postcode' => '1110', 'city' => 'Wien', 'vat_id' => 'ATU00000000',
]);
$this->archive = sys_get_temp_dir().'/clupilot-archive-'.bin2hex(random_bytes(6));
mkdir($this->archive, 0o775, true);
Settings::set(InvoiceArchive::PATH_KEY, $this->archive);
});
afterEach(function () {
if (isset($this->archive) && is_dir($this->archive)) {
exec('rm -rf '.escapeshellarg($this->archive));
}
});
function anInvoice(): Invoice
{
$customer = Customer::factory()->create(['name' => 'Muster GmbH']);
return app(IssueInvoice::class)->forOrders($customer, collect([
Order::factory()->create(['customer_id' => $customer->id, 'amount_cents' => 1900, 'currency' => 'EUR', 'status' => 'paid']),
]));
}
it('queues a copy the moment an invoice is issued', function () {
Queue::fake();
anInvoice();
Queue::assertPushed(ArchiveInvoice::class);
});
it('queues nothing when no archive is configured', function () {
Queue::fake();
Settings::set(InvoiceArchive::PATH_KEY, '');
anInvoice();
Queue::assertNotPushed(ArchiveInvoice::class);
});
it('writes the invoice under its number, grouped by year', function () {
// Seven years of invoices in one directory is a directory nobody opens
// twice.
$invoice = anInvoice();
(new ArchiveInvoice($invoice))->handle(app(InvoiceArchive::class));
$expected = $this->archive.'/'.now()->format('Y').'/'.$invoice->number.'.pdf';
expect(is_file($expected))->toBeTrue()
->and(substr(file_get_contents($expected), 0, 5))->toBe('%PDF-')
->and($invoice->refresh()->exported_at)->not->toBeNull();
});
it('leaves no half-written file behind under the final name', function () {
// Written to a .part and renamed. A rename inside one filesystem is atomic,
// so whatever watches that folder never sees a truncated PDF and mistakes
// it for a finished invoice.
$invoice = anInvoice();
(new ArchiveInvoice($invoice))->handle(app(InvoiceArchive::class));
$year = $this->archive.'/'.now()->format('Y');
expect(glob($year.'/*.part'))->toBe([]);
});
it('does not write the same invoice twice', function () {
// A retry after a timeout that actually succeeded must not do it again.
$invoice = anInvoice();
(new ArchiveInvoice($invoice))->handle(app(InvoiceArchive::class));
$first = $invoice->refresh()->exported_at;
(new ArchiveInvoice($invoice))->handle(app(InvoiceArchive::class));
expect($invoice->refresh()->exported_at->toIso8601String())->toBe($first->toIso8601String());
});
it('refuses to create the archive root, so an unmounted NAS is a failure and not a hole', function () {
// The finding that made this test necessary. mkdir -p on a path whose mount
// is not there SUCCEEDS: it creates the empty directory underneath the
// mountpoint, writes the invoice onto the container's own disk, and reports
// success. An archive that silently is not the archive is worse than none,
// because every check says it worked.
// Faked, or the dispatch-on-issue archives it synchronously into the
// working directory from beforeEach() before this test has said anything.
Queue::fake();
$invoice = anInvoice();
Settings::set(InvoiceArchive::PATH_KEY, sys_get_temp_dir().'/clupilot-not-mounted-'.bin2hex(random_bytes(4)));
expect(fn () => app(InvoiceArchive::class)->store($invoice))
->toThrow(RuntimeException::class);
expect($invoice->refresh()->exported_at)->toBeNull()
// And nothing was left on disk pretending to be an archive.
->and(is_dir(InvoiceArchive::path()))->toBeFalse();
});
it('records why it gave up, on the invoice rather than only in a log', function () {
// Nobody opens an archive to check whether last Tuesday is in it, so the
// failure has to be somewhere a person will pass.
$invoice = anInvoice();
(new ArchiveInvoice($invoice))->failed(new RuntimeException('mount is gone'));
expect($invoice->refresh()->export_error)->toContain('mount is gone');
});
it('sweeps up whatever the queue gave up on', function () {
Queue::fake();
$invoice = anInvoice();
Queue::assertPushed(ArchiveInvoice::class);
// The office connection was down; nothing reached the archive.
expect($invoice->exported_at)->toBeNull();
$this->artisan('clupilot:archive-invoices')->assertExitCode(0);
Queue::assertPushed(ArchiveInvoice::class, 2);
});