242 lines
9.3 KiB
PHP
242 lines
9.3 KiB
PHP
<?php
|
|
|
|
use App\Jobs\ArchiveInvoice;
|
|
use App\Models\Customer;
|
|
use App\Models\Invoice;
|
|
use App\Models\Order;
|
|
use App\Models\ExportTarget;
|
|
use App\Models\InvoiceExport;
|
|
use App\Services\Billing\InvoiceArchive;
|
|
use App\Services\Billing\IssueInvoice;
|
|
use App\Support\CompanyProfile;
|
|
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);
|
|
|
|
$this->target = ExportTarget::create([
|
|
'name' => 'Büro-NAS', 'driver' => ExportTarget::LOCAL, 'path' => $this->archive, 'active' => true,
|
|
]);
|
|
});
|
|
|
|
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 one copy per destination the moment an invoice is issued', function () {
|
|
// One job per pair, not one per invoice: the reason for a second
|
|
// destination is that the first can fail, and a job writing to both would
|
|
// retry the one that worked every time the other did not.
|
|
Queue::fake();
|
|
|
|
ExportTarget::create([
|
|
'name' => 'Storage Box', 'driver' => ExportTarget::LOCAL,
|
|
'path' => $this->archive, 'active' => true,
|
|
]);
|
|
|
|
anInvoice();
|
|
|
|
Queue::assertPushed(ArchiveInvoice::class, 2);
|
|
});
|
|
|
|
it('queues nothing to a destination that is switched off', function () {
|
|
Queue::fake();
|
|
$this->target->update(['active' => false]);
|
|
|
|
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, $this->target))->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(InvoiceExport::query()->where('invoice_id', $invoice->id)->value('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, $this->target))->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, $this->target))->handle(app(InvoiceArchive::class));
|
|
$first = InvoiceExport::query()->where('invoice_id', $invoice->id)->value('exported_at');
|
|
|
|
(new ArchiveInvoice($invoice, $this->target))->handle(app(InvoiceArchive::class));
|
|
|
|
expect(InvoiceExport::query()->where('invoice_id', $invoice->id)->value('exported_at'))->toEqual($first);
|
|
});
|
|
|
|
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();
|
|
$gone = sys_get_temp_dir().'/clupilot-not-mounted-'.bin2hex(random_bytes(4));
|
|
$this->target->update(['path' => $gone]);
|
|
|
|
expect(fn () => app(InvoiceArchive::class)->store($invoice, $this->target->refresh()))
|
|
->toThrow(RuntimeException::class);
|
|
|
|
expect(InvoiceExport::query()->whereNotNull('exported_at')->count())->toBe(0)
|
|
// And nothing was left on disk pretending to be an archive.
|
|
->and(is_dir($gone))->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, $this->target))->failed(new RuntimeException('mount is gone'));
|
|
|
|
expect(InvoiceExport::query()->where('invoice_id', $invoice->id)->value('error'))->toContain('mount is gone');
|
|
});
|
|
|
|
it('sweeps up whatever the queue gave up on', function () {
|
|
Queue::fake();
|
|
|
|
$invoice = anInvoice();
|
|
Queue::assertPushed(ArchiveInvoice::class, 1);
|
|
|
|
// The office connection was down; nothing reached the destination.
|
|
expect(InvoiceExport::query()->whereNotNull('exported_at')->count())->toBe(0);
|
|
|
|
$this->artisan('clupilot:archive-invoices')->assertExitCode(0);
|
|
|
|
Queue::assertPushed(ArchiveInvoice::class, 2);
|
|
});
|
|
|
|
it('puts a handover destination into a folder per day', function () {
|
|
// A NAS that collects wants today's folder. A folder per year is for an
|
|
// archive somebody keeps, and the two do different jobs.
|
|
$this->target->update(['layout' => ExportTarget::BY_DAY]);
|
|
$invoice = anInvoice();
|
|
|
|
(new ArchiveInvoice($invoice, $this->target->refresh()))->handle(app(InvoiceArchive::class));
|
|
|
|
expect(is_file($this->archive.'/'.now()->format('Y-m-d').'/'.$invoice->number.'.pdf'))->toBeTrue();
|
|
});
|
|
|
|
it('removes handover folders past their keep-by, and only those', function () {
|
|
// Safe in a way deleting usually is not: the invoice is a frozen document
|
|
// in the database and its PDF is rendered on demand, so anything removed
|
|
// here can be produced again.
|
|
$this->target->update(['layout' => ExportTarget::BY_DAY, 'keep_days' => 30]);
|
|
|
|
$old = $this->archive.'/'.now()->subDays(40)->format('Y-m-d');
|
|
$recent = $this->archive.'/'.now()->subDays(3)->format('Y-m-d');
|
|
$foreign = $this->archive.'/nicht-von-uns';
|
|
|
|
foreach ([$old, $recent, $foreign] as $dir) {
|
|
mkdir($dir, 0o775, true);
|
|
file_put_contents($dir.'/RE-2026-0001.pdf', 'x');
|
|
}
|
|
|
|
$this->artisan('clupilot:prune-exports')->assertExitCode(0);
|
|
|
|
expect(is_dir($old))->toBeFalse()
|
|
->and(is_dir($recent))->toBeTrue()
|
|
// A prune that deletes what it does not recognise is how an archive
|
|
// loses something nobody was watching.
|
|
->and(is_dir($foreign))->toBeTrue();
|
|
});
|
|
|
|
it('deletes nothing at all where no retention is configured', function () {
|
|
// The default, and deliberately so: a retention rule nobody asked for is a
|
|
// deletion nobody expected.
|
|
$this->target->update(['layout' => ExportTarget::BY_DAY, 'keep_days' => null]);
|
|
|
|
$old = $this->archive.'/'.now()->subYears(3)->format('Y-m-d');
|
|
mkdir($old, 0o775, true);
|
|
|
|
$this->artisan('clupilot:prune-exports')->assertExitCode(0);
|
|
|
|
expect(is_dir($old))->toBeTrue();
|
|
});
|
|
|
|
it('does not prune a destination whose mount has gone away', function () {
|
|
// A cleanup must never be the thing that acts on whatever is underneath an
|
|
// absent mountpoint.
|
|
$gone = sys_get_temp_dir().'/clupilot-absent-'.bin2hex(random_bytes(4));
|
|
$this->target->update(['path' => $gone, 'layout' => ExportTarget::BY_DAY, 'keep_days' => 1]);
|
|
|
|
$this->artisan('clupilot:prune-exports')->assertExitCode(0);
|
|
|
|
expect(is_dir($gone))->toBeFalse();
|
|
});
|
|
|
|
it('leaves the folder readable by whoever collects from it', function () {
|
|
// Not cosmetic. mkdir's mode is masked by the process umask, and a
|
|
// restrictive one leaves the folder 0700 — at which point the account that
|
|
// collects (an rsync over ssh, a backup agent, anything that is not the
|
|
// account that wrote it) cannot enter it, and an unreadable directory looks
|
|
// exactly like an empty one. Reported as "the folder arrives empty", with
|
|
// the invoice sitting in it the whole time.
|
|
$invoice = anInvoice();
|
|
$old = umask(0o077);
|
|
|
|
try {
|
|
(new ArchiveInvoice($invoice, $this->target))->handle(app(InvoiceArchive::class));
|
|
} finally {
|
|
umask($old);
|
|
}
|
|
|
|
$folder = $this->archive.'/'.now()->format('Y');
|
|
$file = $folder.'/'.$invoice->number.'.pdf';
|
|
|
|
expect(substr(sprintf('%o', fileperms($folder)), -3))->toBe('755')
|
|
->and(substr(sprintf('%o', fileperms($file)), -3))->toBe('644');
|
|
});
|