Files
compras/tests/Feature/ReceiptDocumentBlockTest.php
T
2026-11-03 11:51:44 +02:00

128 lines
8.1 KiB
PHP

<?php
use App\Models\GoodsReceipt;
use App\Models\ReceiptDocument;
use App\Services\ReceiptDocuments;
use App\Services\ReceiptWorkflow;
use App\Services\RequisitionWorkflow;
use Illuminate\Database\QueryException;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Inertia\Testing\AssertableInertia as Assert;
require_once __DIR__.'/../Support/ReceiptFixture.php';
require_once __DIR__.'/../Support/SupplyFixture.php';
beforeEach(function () {
$this->f = receiptFixture();
$this->actingAs($this->f['receiver']);
$this->receipt = app(ReceiptWorkflow::class)->confirm($this->f['receiver'], $this->f['order'], receiptPayload($this->f));
$this->directory = storage_path('framework/testing/receipt-block-'.Str::uuid());
Storage::set('receipt_documents', Storage::build(['driver' => 'local', 'root' => $this->directory, 'throw' => true]));
$this->image = UploadedFile::fake()->createWithContent('guia.png', base64_decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Wl6E4sAAAAASUVORK5CYII='));
$this->key = (string) Str::uuid();
$this->url = '/recepcoes/'.$this->receipt->id;
});
afterEach(function () {
File::deleteDirectory($this->directory);
});
function receiptDocumentInput(object $test, ?string $key = null, ?UploadedFile $file = null): array
{
return ['operation_key' => $key ?? $test->key, 'document_type' => 'delivery_note', 'file' => $file ?? $test->image];
}
test('receipt document block uploads reuses and downloads evidence without repeating receipt stock or approvals', function () {
$receiptBefore = $this->receipt->getAttributes();
$before = [];
foreach (['goods_receipts', 'goods_receipt_items', 'stock_movements', 'approval_steps', 'budget_entries', 'purchase_orders'] as $table) {
$before[$table] = DB::table($table)->orderBy('id')->get()->toArray();
}
$this->post($this->url.'/documentos', receiptDocumentInput($this))->assertRedirect($this->url.'#receipt-documents');
$document = ReceiptDocument::query()->sole();
$this->post($this->url.'/documentos', receiptDocumentInput($this))->assertRedirect();
$this->post($this->url.'/documentos', receiptDocumentInput($this, (string) Str::uuid()))->assertRedirect();
expect(ReceiptDocument::query()->count())->toBe(1)->and(Storage::disk('receipt_documents')->allFiles())->toHaveCount(1);
expect(DB::table('audit_logs')->where('action', 'receipt.document_added')->count())->toBe(1);
expect(DB::table('audit_logs')->where('action', 'receipt.document_reused')->count())->toBe(1);
$this->get($this->url)->assertInertia(fn (Assert $p) => $p->where('documentsReady', true)->where('canAddDocuments', true)
->has('documents', 1)->missing('documents.0.path')->where('documents.0.id', $document->id));
$response = $this->get($this->url.'/documentos/'.$document->id)->assertOk()->assertHeader('Content-Type', 'application/octet-stream')
->assertHeader('X-Content-Type-Options', 'nosniff')->assertHeader('Cache-Control', 'no-store, private');
expect(hash('sha256', $response->streamedContent()))->toBe($document->sha256);
foreach ($before as $table => $rows) {
expect(DB::table($table)->orderBy('id')->get()->toArray())->toEqual($rows);
}
expect($this->receipt->fresh()->getAttributes())->toBe($receiptBefore);
});
test('receipt document block preserves role independence tenant and receipt scopes', function () {
$this->post($this->url.'/documentos', receiptDocumentInput($this))->assertRedirect();
$document = ReceiptDocument::query()->sole();
$this->actingAs($this->f['issuer'])->post($this->url.'/documentos', receiptDocumentInput($this, (string) Str::uuid()))->assertForbidden();
$this->get($this->url.'/documentos/'.$document->id)->assertOk();
supplyGrant($this->f['receiver'], ['warehouse']);
supplyGrant($this->f['issuer'], ['warehouse']);
$this->post($this->url.'/documentos', receiptDocumentInput($this, (string) Str::uuid()))->assertForbidden();
$foreign = receiptFixture();
$this->actingAs($foreign['receiver'])->get($this->url.'/documentos/'.$document->id)->assertNotFound();
$this->post($this->url.'/documentos', receiptDocumentInput($this))->assertNotFound();
$this->actingAs($this->f['receiver']);
$another = app(ReceiptWorkflow::class)->confirm($this->f['receiver'], $this->f['order'], receiptPayload($this->f, '0.100'));
$this->get('/recepcoes/'.$another->id.'/documentos/'.$document->id)->assertNotFound();
supplyGrant($this->f['receiver'], ['warehouse']);
DB::table('organization_memberships')->where('user_id', $this->f['receiver']->id)->update(['maintenance' => true]);
$this->post($this->url.'/documentos', receiptDocumentInput($this, (string) Str::uuid()))->assertForbidden();
$this->get($this->url.'/documentos/'.$document->id)->assertForbidden();
});
test('receipt document block rejects changed replay and invalid files and detects tampering', function () {
$this->post($this->url.'/documentos', receiptDocumentInput($this))->assertRedirect();
$document = ReceiptDocument::query()->sole();
$this->withHeader('Accept', 'application/json')->post($this->url.'/documentos', receiptDocumentInput($this, null, UploadedFile::fake()->createWithContent('guia.png', file_get_contents($this->image->getPathname()).'changed')))->assertUnprocessable();
$this->post($this->url.'/documentos', receiptDocumentInput($this, (string) Str::uuid(), UploadedFile::fake()->create('bad.html', 1, 'text/html')))->assertUnprocessable();
$this->post($this->url.'/documentos', receiptDocumentInput($this, (string) Str::uuid(), UploadedFile::fake()->create('large.pdf', 2049, 'application/pdf')))->assertUnprocessable();
expect(ReceiptDocument::query()->count())->toBe(1)->and(Storage::disk('receipt_documents')->allFiles())->toHaveCount(1);
foreach (['update', 'delete'] as $operation) {
try {
DB::transaction(function () use ($document, $operation) {
$query = DB::table('receipt_documents')->where('id', $document->id);
$operation === 'update' ? $query->update(['original_name' => 'changed.png']) : $query->delete();
});
test()->fail('Document metadata must remain append-only');
} catch (QueryException) {
expect($document->fresh()->original_name)->toBe('guia.png');
}
}
Storage::disk('receipt_documents')->put($document->path, 'TAMPERED');
$this->get($this->url.'/documentos/'.$document->id)->assertStatus(409);
Storage::disk('receipt_documents')->delete($document->path);
$this->get($this->url.'/documentos/'.$document->id)->assertNotFound();
});
test('receipt document block rolls back metadata and private file if audit fails then permits safe retry', function () {
$this->partialMock(RequisitionWorkflow::class)->shouldReceive('audit')->once()->andThrow(new RuntimeException('Injected document audit failure'));
try {
app(ReceiptDocuments::class)->add($this->f['receiver'], $this->receipt, receiptDocumentInput($this), $this->image);
test()->fail('Expected rollback');
} catch (RuntimeException $error) {
expect($error->getMessage())->toBe('Injected document audit failure');
}
expect(ReceiptDocument::query()->count())->toBe(0)->and(Storage::disk('receipt_documents')->allFiles())->toBe([]);
app()->forgetInstance(RequisitionWorkflow::class);
$document = app(ReceiptDocuments::class)->add($this->f['receiver'], $this->receipt, receiptDocumentInput($this), $this->image);
expect($document->id)->toBeGreaterThan(0)->and(ReceiptDocument::query()->count())->toBe(1);
});
test('receipt document block leaves the established receipt page usable before installation', function () {
Schema::drop('receipt_documents');
$this->get($this->url)->assertOk()->assertInertia(fn (Assert $p) => $p->where('documentsReady', false)->where('canAddDocuments', false)->has('documents', 0));
$this->post($this->url.'/documentos', receiptDocumentInput($this))->assertStatus(503);
expect(GoodsReceipt::query()->whereKey($this->receipt->id)->exists())->toBeTrue();
});