154 lines
11 KiB
PHP
154 lines
11 KiB
PHP
<?php
|
|
|
|
use App\Enums\PurchasingRole;
|
|
use App\Http\Middleware\EnsureWorkContext;
|
|
use App\Models\AuditLog;
|
|
use App\Models\Department;
|
|
use App\Models\Supplier;
|
|
use App\Models\Tenant;
|
|
use App\Models\User;
|
|
use App\Services\AdministrativeGeography;
|
|
use App\Services\RequisitionWorkflow;
|
|
use Illuminate\Support\Facades\Schema;
|
|
use Illuminate\Validation\ValidationException;
|
|
|
|
beforeEach(function () {
|
|
$this->withoutVite();
|
|
config(['geography.enabled' => true]);
|
|
$this->company = Tenant::query()->create(['name' => 'Geografia sintética']);
|
|
$department = Department::query()->create(['tenant_id' => $this->company->id, 'name' => 'Operações']);
|
|
$this->buyer = User::factory()->create(['tenant_id' => $this->company->id, 'department_id' => $department->id, 'purchasing_role' => PurchasingRole::Procurement]);
|
|
$this->admin = User::factory()->create(['tenant_id' => $this->company->id, 'department_id' => $department->id, 'purchasing_role' => PurchasingRole::Admin]);
|
|
$this->actingAs($this->buyer);
|
|
$this->reference = ['country' => 'MZ', 'catalogue' => config('geography.source_sha256'), 'record_id' => '2'];
|
|
$this->data = ['name' => 'Fornecedor geográfico', 'nuit' => '123456789', 'contact' => 'Contacto', 'email' => 'geo@example.test', 'phone' => '123456789', 'address' => 'Morada original estrangeira ou nacional', 'category' => 'Material', 'beneficiaries' => 'Beneficiário sintético', 'document_references' => 'Referências sintéticas', 'diligence_notes' => 'Diligência sintética sem dados reais', 'blocked' => false, 'next_review' => now()->addYear()->format('Y-m-d')];
|
|
});
|
|
|
|
test('catalogue preserves all source records and quarantines ambiguity without inventing nodes', function () {
|
|
$catalogue = app(AdministrativeGeography::class)->catalogue();
|
|
expect($catalogue['manifest']['records'])->toBe(20407)->and($catalogue['manifest']['selectable'])->toBe(9678)->and($catalogue['manifest']['duplicate_code_groups'])->toBe(48)->and($catalogue['manifest']['edition'])->toBeNull();
|
|
expect(count($catalogue['records']))->toBe(20407);
|
|
$ids = [];
|
|
$preserved = true;
|
|
$quarantined = true;
|
|
$parentsValid = true;
|
|
foreach ($catalogue['records'] as $record) {
|
|
$ids[$record['id']] = $record;
|
|
$preserved = $preserved && $record['name'] === $record['raw']['Designacao'];
|
|
if ($record['issues']) {
|
|
$quarantined = $quarantined && ! $record['selectable'];
|
|
}
|
|
}
|
|
foreach ($ids as $record) {
|
|
if ($record['parent_id'] !== null && $record['selectable']) {
|
|
$parentsValid = $parentsValid && $ids[$record['parent_id']]['selectable'];
|
|
}
|
|
}
|
|
expect($preserved)->toBeTrue()->and($quarantined)->toBeTrue()->and($parentsValid)->toBeTrue();
|
|
});
|
|
|
|
test('only authenticated company users can browse confirmed paginated locations', function () {
|
|
$this->getJson('/api/divisao-administrativa')->assertOk()->assertJsonCount(11, 'data')->assertJsonPath('data.0.level', 1);
|
|
$this->getJson('/api/divisao-administrativa?parent=2')->assertOk()->assertJsonPath('data.0.parent_id', '2');
|
|
$this->getJson('/api/divisao-administrativa?page=0')->assertUnprocessable();
|
|
$this->getJson('/api/divisao-administrativa?parent=999999')->assertUnprocessable();
|
|
auth()->logout();
|
|
$this->getJson('/api/divisao-administrativa')->assertUnauthorized();
|
|
});
|
|
|
|
test('inconsistent locations and stale catalogue versions cannot be selected', function () {
|
|
$service = app(AdministrativeGeography::class);
|
|
foreach (['duplicate_code_path', 'missing_parent', 'blank_designation', 'ambiguous_parent', 'ancestor_requires_review'] as $issue) {
|
|
$record = collect($service->catalogue()['records'])->first(fn ($record) => in_array($issue, $record['issues'], true));
|
|
expect(fn () => $service->canonical([...$this->reference, 'record_id' => $record['id']]))->toThrow(ValidationException::class);
|
|
}
|
|
expect(fn () => $service->canonical([...$this->reference, 'catalogue' => str_repeat('0', 64)]))->toThrow(ValidationException::class);
|
|
});
|
|
|
|
test('supplier registration reuses geographic reference and preserves address and independent review', function () {
|
|
$this->post('/fornecedores', [...$this->data, 'geography_reference' => $this->reference])->assertSessionHasNoErrors();
|
|
$supplier = Supplier::query()->sole();
|
|
expect($supplier->geography_reference)->toBe($this->reference)->and($supplier->address)->toBe($this->data['address'])->and($supplier->status)->toBe('pending');
|
|
$this->get('/fornecedores/'.$supplier->id)->assertOk();
|
|
expect(AuditLog::query()->where('action', 'supplier.created')->sole()->after['geography_reference'])->toBe($this->reference);
|
|
});
|
|
|
|
test('supplier geography changes follow the native version and revalidation circuit', function () {
|
|
$supplier = Supplier::query()->create([...$this->data, 'tenant_id' => $this->company->id, 'created_by' => $this->buyer->id, 'status' => 'active']);
|
|
$url = '/fornecedores/'.$supplier->id;
|
|
$this->put($url, [...$this->data, 'version' => 1, 'geography_reference' => $this->reference])->assertSessionHasNoErrors();
|
|
expect($supplier->refresh()->status)->toBe('pending')->and($supplier->version)->toBe(2)->and($supplier->address)->toBe($this->data['address']);
|
|
$this->put($url, [...$this->data, 'version' => 1, 'geography_reference' => null])->assertSessionHasErrors('version');
|
|
$this->put($url, [...$this->data, 'version' => 2, 'geography_reference' => $this->reference])->assertSessionHasNoErrors();
|
|
expect($supplier->refresh()->version)->toBe(2);
|
|
$this->put($url, [...$this->data, 'version' => 2])->assertSessionHasNoErrors();
|
|
expect($supplier->refresh()->geography_reference)->toBe($this->reference);
|
|
$this->put($url, [...$this->data, 'version' => 2, 'geography_reference' => null])->assertSessionHasNoErrors();
|
|
expect($supplier->refresh()->geography_reference)->toBeNull()->and($supplier->address)->toBe($this->data['address']);
|
|
});
|
|
|
|
test('unclassified and foreign supplier addresses remain supported without guessing country', function () {
|
|
$this->post('/fornecedores', $this->data)->assertSessionHasNoErrors();
|
|
expect(Supplier::query()->sole()->geography_reference)->toBeNull();
|
|
$this->post('/fornecedores', [...$this->data, 'nuit' => '987654321', 'geography_reference' => [...$this->reference, 'record_id' => '999999']])->assertSessionHasErrors('geography_reference');
|
|
expect(Supplier::query()->count())->toBe(1);
|
|
});
|
|
|
|
test('geographic filter includes descendant locations and remains tenant scoped', function () {
|
|
$service = app(AdministrativeGeography::class);
|
|
$child = collect($service->catalogue()['records'])->first(fn ($record) => $record['selectable'] && $record['level'] > 1 && $record['codes'][0] === '01');
|
|
$supplier = Supplier::query()->create([...$this->data, 'geography_reference' => [...$this->reference, 'record_id' => $child['id']], 'tenant_id' => $this->company->id, 'created_by' => $this->buyer->id]);
|
|
$other = Tenant::query()->create(['name' => 'Outra empresa']);
|
|
Supplier::query()->create([...$this->data, 'tenant_id' => $other->id, 'created_by' => $this->buyer->id, 'geography_reference' => $this->reference]);
|
|
$this->get('/fornecedores?location=2')->assertInertia(fn ($page) => $page->has('suppliers.data', 1)->where('suppliers.data.0.id', $supplier->id));
|
|
$this->get('/fornecedores?location=999999')->assertSessionHasErrors('geography_reference');
|
|
});
|
|
|
|
test('only current administrators can change company location without changing approval rules', function () {
|
|
$payload = ['geography_reference' => $this->reference, 'expected_reference' => null];
|
|
$this->post('/empresa/localizacao', $payload)->assertForbidden();
|
|
$before = $this->company->refresh()->only(['first_limit_minor', 'second_limit_minor', 'approval_includes_tax']);
|
|
$this->actingAs($this->admin)->post('/empresa/localizacao', $payload)->assertSessionHasNoErrors();
|
|
expect($this->company->refresh()->geography_reference)->toBe($this->reference)->and($this->company->only(array_keys($before)))->toBe($before);
|
|
$this->post('/empresa/localizacao', ['geography_reference' => null, 'expected_reference' => null])->assertSessionHasErrors('geography_reference');
|
|
$this->post('/empresa/localizacao', ['geography_reference' => $this->reference, 'expected_reference' => $this->reference])->assertSessionHasNoErrors();
|
|
expect(AuditLog::query()->where('action', 'company.geography_updated')->count())->toBe(1);
|
|
$this->admin->forceFill(['purchasing_role' => PurchasingRole::Finance])->save();
|
|
expect($this->admin->fresh()->purchasing_role)->toBe(PurchasingRole::Finance);
|
|
$this->withoutMiddleware(EnsureWorkContext::class);
|
|
$this->post('/empresa/localizacao', ['geography_reference' => null, 'expected_reference' => $this->reference])->assertForbidden();
|
|
});
|
|
|
|
test('disabled feature and missing schema do not accept geographic changes', function () {
|
|
config(['geography.enabled' => false]);
|
|
$this->getJson('/api/divisao-administrativa')->assertServiceUnavailable();
|
|
$this->post('/fornecedores', [...$this->data, 'geography_reference' => $this->reference])->assertSessionHasErrors('geography_reference');
|
|
$this->post('/fornecedores', $this->data)->assertSessionHasNoErrors();
|
|
config(['geography.enabled' => true]);
|
|
Schema::table('suppliers', fn ($table) => $table->dropColumn('geography_reference'));
|
|
$this->getJson('/api/divisao-administrativa')->assertServiceUnavailable();
|
|
});
|
|
|
|
test('catalogue integrity mismatch fails closed', function () {
|
|
config(['geography.catalogue_sha256' => str_repeat('0', 64)]);
|
|
$this->getJson('/api/divisao-administrativa')->assertServiceUnavailable();
|
|
});
|
|
|
|
test('geographic choices remain paginated without losing older locations', function () {
|
|
$service = app(AdministrativeGeography::class);
|
|
$group = collect($service->catalogue()['records'])->filter(fn ($record) => $record['selectable'] && $record['parent_id'] !== null)->groupBy('parent_id')->first(fn ($records) => $records->count() > 50);
|
|
$parent = $group->first()['parent_id'];
|
|
$first = $service->children($parent);
|
|
$second = $service->children($parent, 2);
|
|
expect($first['data'])->toHaveCount(50)->and($first['has_more'])->toBeTrue()->and($second['data'])->not->toBeEmpty();
|
|
expect(array_intersect(array_column($first['data'], 'id'), array_column($second['data'], 'id')))->toBeEmpty();
|
|
});
|
|
|
|
test('audit failure rolls back supplier geography and its validation state together', function () {
|
|
$supplier = Supplier::query()->create([...$this->data, 'tenant_id' => $this->company->id, 'created_by' => $this->buyer->id, 'status' => 'active']);
|
|
$this->partialMock(RequisitionWorkflow::class)->shouldReceive('audit')->andThrow(new RuntimeException('Synthetic audit failure'));
|
|
$this->withoutExceptionHandling();
|
|
expect(fn () => $this->put('/fornecedores/'.$supplier->id, [...$this->data, 'version' => 1, 'geography_reference' => $this->reference]))->toThrow(RuntimeException::class, 'Synthetic audit failure');
|
|
expect($supplier->refresh()->geography_reference)->toBeNull()->and($supplier->status)->toBe('active')->and($supplier->version)->toBe(1);
|
|
});
|