'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'); });