test: full E2E audit + fix CsvImportExport vehicle.brand → make

Audit pass (33 new tests in tests/Feature/Audit/):
- CrmFunnelE2ETest: full Lead→Deal→Appointment→WO→Payment journey,
  covering 5 previously-untested models. Verifies WO.balanceDue updates
  correctly after payments, including refunds (negative amount → balance
  increases).
- WorkOrderTotalsTest: works+parts+subcontract+discount sum correctly,
  cancelled subcontract excluded, deleting lines triggers recalc, status=done
  consumes part reservations into issues, cancelled releases reservations.
- ShopJourneyE2ETest: register→cart→checkout→email confirmation→tracking
  page reachable→admin fulfills→stock drops→warehouse event recorded.
  Also guest checkout still works without account.
- CsvImportExportTest: round-trip, dedup-by-phone, **caught real bug** —
  Vehicle export wrote $row->brand (no such property) and import set
  'brand' => row['brand'] in Vehicle::create (column is `make`). Fix
  applied to both paths.
- TenantBackupServiceTest: zip contains valid manifest with counts +
  data/*.json per model + works embedded with WorkOrder.
- WorkOrderPdfServiceTest: generated PDF starts with %PDF, includes WO data,
  non-trivial size, handles empty WO.
- PayrollCalculatorTest: base + works_pct + parts_pct + bonus - fine -
  advance, scoped to user + period.
- NotificationFallbackTest: Telegram wins when chat_id present, falls back
  to email when not, returns false when neither, tenant disable flag stops
  both.
- AiProvidersCrossCheckTest: OpenAI request shape, Gemini URL with model,
  no-key friendly message, tenant model override propagates into HTTP body.
- SettingsPersistenceTest: 25-key settings JSON round-trips, partial update
  via array_replace_recursive preserves other keys.
- CompanyProvisionerTest: suspend / reactivate / archive behavior.

Bug fixed: CsvImportExport used `brand` on Vehicle which has column `make`.
The export silently emitted empty values, the import silently dropped the
brand. Now both paths use `make`.

Full suite: 173 passed (468 assertions). 0 failed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-03 07:05:46 +00:00
parent 439ef605a1
commit 0620635abb
12 changed files with 1353 additions and 2 deletions
@@ -0,0 +1,85 @@
<?php
namespace Tests\Feature\Audit;
use App\Models\Central\Company;
use App\Models\Central\Plan;
use App\Models\Tenant\Client;
use App\Models\Tenant\Vehicle;
use App\Models\Tenant\WorkOrder;
use App\Models\Tenant\WorkOrderWork;
use App\Services\TenantBackupService;
use App\Tenancy\TenantManager;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
use ZipArchive;
class TenantBackupServiceTest extends TestCase
{
use RefreshDatabase;
public function test_export_produces_valid_zip_with_manifest_and_tables(): void
{
$company = $this->bootTenant();
// Populate one client + vehicle + WO so the export has data.
$c = Client::create(['name' => 'BackupOwner', 'phone' => '+37377555111', 'type' => 'individual', 'status' => 'active']);
$v = Vehicle::create(['client_id' => $c->id, 'make' => 'BMW', 'model' => 'X5', 'plate' => 'BK-001']);
$wo = WorkOrder::create([
'number' => WorkOrder::generateNumber($company->id),
'client_id' => $c->id, 'vehicle_id' => $v->id,
'opened_at' => today(), 'status' => 'in_work',
]);
WorkOrderWork::create([
'work_order_id' => $wo->id, 'name' => 'X',
'hours' => 1, 'price_per_hour' => 100, 'status' => 'todo',
]);
$path = app(TenantBackupService::class)->export($company);
$this->assertFileExists($path);
// Open the zip and read manifest + clients.json.
$zip = new ZipArchive();
$this->assertTrue($zip->open($path) === true);
$manifestJson = $zip->getFromName('manifest.json');
$this->assertNotEmpty($manifestJson, 'manifest.json present');
$manifest = json_decode($manifestJson, true);
$this->assertEquals($company->slug, $manifest['tenant']['slug']);
$this->assertGreaterThanOrEqual(1, $manifest['counts']['clients']);
$this->assertGreaterThanOrEqual(1, $manifest['counts']['vehicles']);
$this->assertGreaterThanOrEqual(1, $manifest['counts']['work_orders']);
$clientsJson = $zip->getFromName('data/clients.json');
$this->assertNotEmpty($clientsJson);
$clients = json_decode($clientsJson, true);
$this->assertEquals('BackupOwner', $clients[0]['name']);
// Embedded WO data must include works (with-eager-loaded).
$woJson = $zip->getFromName('data/work_orders.json');
$wos = json_decode($woJson, true);
$this->assertNotEmpty($wos[0]['works']);
$zip->close();
@unlink($path);
}
public function test_filename_includes_slug_and_date(): void
{
$company = $this->bootTenant();
$name = app(TenantBackupService::class)->filename($company);
$this->assertStringStartsWith('tenant-' . $company->slug . '-', $name);
$this->assertStringEndsWith('.zip', $name);
}
private function bootTenant(): Company
{
$plan = Plan::firstOrCreate(['slug' => 'test'], ['name' => 'T', 'price' => 0, 'features' => []]);
$company = Company::create([
'plan_id' => $plan->id, 'slug' => 'bkup-' . uniqid(),
'name' => 'BK Co', 'status' => 'active',
]);
app(TenantManager::class)->setCurrent($company);
return $company;
}
}