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:
@@ -0,0 +1,114 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Audit;
|
||||
|
||||
use App\Models\Central\Company;
|
||||
use App\Models\Central\Plan;
|
||||
use App\Services\Ai\AiAssistantService;
|
||||
use App\Tenancy\TenantManager;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* The 3 AI providers share infrastructure but each has a different request
|
||||
* shape. After the model selector refactor, verify each provider still uses
|
||||
* the configured model + key in the actual HTTP call.
|
||||
*/
|
||||
class AiProvidersCrossCheckTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_openai_singleshot_uses_configured_model_and_sends_messages(): void
|
||||
{
|
||||
$this->bootCompany('gpt', 'gpt-fake');
|
||||
|
||||
Http::fake(['api.openai.com/*' => Http::response([
|
||||
'choices' => [['message' => ['content' => 'OpenAI response']]],
|
||||
'usage' => ['prompt_tokens' => 50, 'completion_tokens' => 12],
|
||||
'model' => 'gpt-4o-mini',
|
||||
])]);
|
||||
|
||||
[$reply, $meta] = app(AiAssistantService::class)->singleShot('system here', 'user prompt', 'gpt');
|
||||
|
||||
$this->assertEquals('OpenAI response', $reply);
|
||||
$this->assertEquals('gpt', $meta['provider']);
|
||||
Http::assertSent(function ($req) {
|
||||
if (! str_contains($req->url(), 'openai.com')) return false;
|
||||
$body = json_decode($req->body(), true);
|
||||
return $body['model'] === 'gpt-4o-mini'
|
||||
&& $body['messages'][0]['role'] === 'system'
|
||||
&& str_contains($body['messages'][0]['content'], 'system here');
|
||||
});
|
||||
}
|
||||
|
||||
public function test_gemini_singleshot_hits_url_with_configured_model(): void
|
||||
{
|
||||
$this->bootCompany('gemini', 'gem-fake');
|
||||
|
||||
Http::fake(['generativelanguage.googleapis.com/*' => Http::response([
|
||||
'candidates' => [['content' => ['parts' => [['text' => 'Gemini response']]]]],
|
||||
'usageMetadata' => ['promptTokenCount' => 30],
|
||||
])]);
|
||||
|
||||
[$reply, $meta] = app(AiAssistantService::class)->singleShot('s', 'u', 'gemini');
|
||||
|
||||
$this->assertEquals('Gemini response', $reply);
|
||||
Http::assertSent(fn ($req) => str_contains($req->url(), 'gemini-1.5-flash:generateContent'));
|
||||
}
|
||||
|
||||
public function test_no_api_key_returns_friendly_message_without_http(): void
|
||||
{
|
||||
$this->bootCompany('claude', null);
|
||||
|
||||
Http::fake();
|
||||
[$reply, $meta] = app(AiAssistantService::class)->singleShot('s', 'u', 'claude');
|
||||
|
||||
$this->assertStringContainsString('API key', $reply);
|
||||
$this->assertEquals('no_api_key', $meta['error']);
|
||||
Http::assertNothingSent();
|
||||
}
|
||||
|
||||
public function test_tenant_model_override_propagates_into_request(): void
|
||||
{
|
||||
$plan = Plan::firstOrCreate(['slug' => 'test'], ['name' => 'T', 'price' => 0, 'features' => []]);
|
||||
$company = Company::create([
|
||||
'plan_id' => $plan->id, 'slug' => 'override-' . uniqid(),
|
||||
'name' => 'O', 'status' => 'active',
|
||||
'settings' => [
|
||||
'ai' => [
|
||||
'claude_key' => 'sk-fake',
|
||||
'models' => ['claude' => 'claude-opus-4-7'],
|
||||
],
|
||||
],
|
||||
]);
|
||||
app(TenantManager::class)->setCurrent($company);
|
||||
|
||||
Http::fake(['api.anthropic.com/*' => Http::response([
|
||||
'content' => [['type' => 'text', 'text' => 'ok']],
|
||||
'usage' => ['input_tokens' => 1, 'output_tokens' => 1],
|
||||
])]);
|
||||
|
||||
app(AiAssistantService::class)->singleShot('s', 'u', 'claude');
|
||||
|
||||
Http::assertSent(function ($req) {
|
||||
$body = json_decode($req->body(), true);
|
||||
return $body['model'] === 'claude-opus-4-7';
|
||||
});
|
||||
}
|
||||
|
||||
private function bootCompany(string $provider, ?string $key): Company
|
||||
{
|
||||
$plan = Plan::firstOrCreate(['slug' => 'test'], ['name' => 'T', 'price' => 0, 'features' => []]);
|
||||
$company = Company::create([
|
||||
'plan_id' => $plan->id, 'slug' => 'ai-' . uniqid(),
|
||||
'name' => 'AI Co', 'status' => 'active',
|
||||
'settings' => ['ai' => [
|
||||
'default_provider' => $provider,
|
||||
"{$provider}_key" => $key,
|
||||
]],
|
||||
]);
|
||||
app(TenantManager::class)->setCurrent($company);
|
||||
return $company;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Audit;
|
||||
|
||||
use App\Models\Central\Company;
|
||||
use App\Models\Central\Plan;
|
||||
use App\Services\CompanyProvisioner;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class CompanyProvisionerTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_suspend_changes_status_to_suspended(): void
|
||||
{
|
||||
$company = $this->makeCompany('active');
|
||||
app(CompanyProvisioner::class)->suspend($company);
|
||||
$this->assertEquals('suspended', $company->fresh()->status);
|
||||
}
|
||||
|
||||
public function test_reactivate_restores_active_status(): void
|
||||
{
|
||||
$company = $this->makeCompany('suspended');
|
||||
app(CompanyProvisioner::class)->reactivate($company);
|
||||
$this->assertEquals('active', $company->fresh()->status);
|
||||
}
|
||||
|
||||
public function test_archive_marks_status_and_soft_deletes(): void
|
||||
{
|
||||
$company = $this->makeCompany('active');
|
||||
$id = $company->id;
|
||||
app(CompanyProvisioner::class)->archive($company);
|
||||
|
||||
$row = Company::withTrashed()->find($id);
|
||||
$this->assertEquals('archived', $row->status);
|
||||
$this->assertNotNull($row->deleted_at, 'soft-deleted');
|
||||
|
||||
// Default (with SoftDeletes scope) no longer returns it.
|
||||
$this->assertNull(Company::find($id));
|
||||
}
|
||||
|
||||
private function makeCompany(string $status): Company
|
||||
{
|
||||
$plan = Plan::firstOrCreate(['slug' => 'test'], ['name' => 'T', 'price' => 0, 'features' => []]);
|
||||
return Company::create([
|
||||
'plan_id' => $plan->id, 'slug' => 'cp-' . uniqid(),
|
||||
'name' => 'CP Co', 'status' => $status,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Audit;
|
||||
|
||||
use App\Models\Central\Company;
|
||||
use App\Models\Central\Plan;
|
||||
use App\Models\Tenant\Appointment;
|
||||
use App\Models\Tenant\Client;
|
||||
use App\Models\Tenant\Deal;
|
||||
use App\Models\Tenant\Lead;
|
||||
use App\Models\Tenant\Payment;
|
||||
use App\Models\Tenant\Vehicle;
|
||||
use App\Models\Tenant\WorkOrder;
|
||||
use App\Models\Tenant\WorkOrderWork;
|
||||
use App\Tenancy\TenantManager;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* End-to-end: a single customer travels through the CRM funnel.
|
||||
* Exercises 5 previously-untested models (Lead, Deal, Appointment, Payment)
|
||||
* + their relations to Client / Vehicle / WorkOrder.
|
||||
*/
|
||||
class CrmFunnelE2ETest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_full_journey_lead_to_payment_with_balance_due(): void
|
||||
{
|
||||
$ctx = $this->bootTenant();
|
||||
|
||||
// 1. Inbound lead (anonymous, no client yet).
|
||||
$lead = Lead::create([
|
||||
'name' => 'Ion Pop',
|
||||
'phone' => '+37369123456',
|
||||
'email' => 'ion@example.com',
|
||||
'car' => 'BMW',
|
||||
'model' => 'X5',
|
||||
'message' => 'Vreau diagnoză + schimb plăcuțe',
|
||||
'source' => 'site',
|
||||
'status' => 'new',
|
||||
]);
|
||||
|
||||
$this->assertEquals('new', $lead->status);
|
||||
$this->assertNull($lead->client_id);
|
||||
|
||||
// 2. Convert lead → Client + Deal. (Mirrors what UI Lead-convert action does.)
|
||||
$client = Client::create([
|
||||
'name' => $lead->name, 'phone' => $lead->phone, 'email' => $lead->email,
|
||||
'type' => 'individual', 'status' => 'active',
|
||||
]);
|
||||
$vehicle = Vehicle::create([
|
||||
'client_id' => $client->id,
|
||||
'make' => $lead->car, 'model' => $lead->model,
|
||||
'plate' => 'TST-001',
|
||||
]);
|
||||
$deal = Deal::create([
|
||||
'client_id' => $client->id,
|
||||
'vehicle_id' => $vehicle->id,
|
||||
'name' => 'BMW X5 — frâne + diag',
|
||||
'price' => 0,
|
||||
'stage' => 'new',
|
||||
]);
|
||||
$lead->update([
|
||||
'client_id' => $client->id,
|
||||
'vehicle_id' => $vehicle->id,
|
||||
'deal_id' => $deal->id,
|
||||
'status' => 'won',
|
||||
'converted_at' => now(),
|
||||
]);
|
||||
|
||||
// Verify Lead now linked.
|
||||
$this->assertEquals($client->id, $lead->fresh()->client_id);
|
||||
$this->assertEquals($deal->id, $lead->fresh()->deal_id);
|
||||
$this->assertNotNull($lead->fresh()->converted_at);
|
||||
|
||||
// 3. Schedule appointment from deal.
|
||||
$appt = Appointment::create([
|
||||
'client_id' => $client->id, 'vehicle_id' => $vehicle->id,
|
||||
'deal_id' => $deal->id,
|
||||
'date' => today()->addDays(2)->toDateString(),
|
||||
'time_start' => '10:00:00', 'time_end' => '12:00:00',
|
||||
'title' => 'Diagnoză BMW X5',
|
||||
'status' => 'scheduled',
|
||||
]);
|
||||
$this->assertEquals($deal->id, $appt->deal_id);
|
||||
|
||||
// 4. Open WorkOrder from appointment.
|
||||
$wo = WorkOrder::create([
|
||||
'number' => WorkOrder::generateNumber($ctx['company']->id),
|
||||
'client_id' => $client->id,
|
||||
'vehicle_id' => $vehicle->id,
|
||||
'deal_id' => $deal->id,
|
||||
'appointment_id' => $appt->id,
|
||||
'opened_at' => today(),
|
||||
'status' => 'in_work',
|
||||
'complaint' => $lead->message,
|
||||
]);
|
||||
|
||||
// 5. Add labor — WO total should update via saved hook.
|
||||
WorkOrderWork::create([
|
||||
'work_order_id' => $wo->id,
|
||||
'name' => 'Schimb plăcuțe față',
|
||||
'hours' => 1.5,
|
||||
'price_per_hour' => 400,
|
||||
'status' => 'todo',
|
||||
]);
|
||||
|
||||
$wo->refresh();
|
||||
$this->assertEquals(600.0, (float) $wo->total, '1.5h × 400 = 600');
|
||||
$this->assertEquals(600.0, $wo->balanceDue(), 'no payments yet, full due');
|
||||
|
||||
// 6. Customer pays 250 cash.
|
||||
Payment::create([
|
||||
'client_id' => $client->id,
|
||||
'work_order_id' => $wo->id,
|
||||
'paid_at' => today(),
|
||||
'amount' => 250,
|
||||
'method' => 'cash',
|
||||
]);
|
||||
|
||||
$wo->refresh();
|
||||
$this->assertEquals(250.0, $wo->paidAmount());
|
||||
$this->assertEquals(350.0, $wo->balanceDue(), '600 − 250 = 350');
|
||||
|
||||
// 7. Customer pays the rest.
|
||||
Payment::create([
|
||||
'client_id' => $client->id,
|
||||
'work_order_id' => $wo->id,
|
||||
'paid_at' => today(),
|
||||
'amount' => 350,
|
||||
'method' => 'card',
|
||||
]);
|
||||
|
||||
$wo->refresh();
|
||||
$this->assertEquals(600.0, $wo->paidAmount());
|
||||
$this->assertEquals(0.0, $wo->balanceDue());
|
||||
|
||||
// 8. Close the WO. master_id wasn't set, so no push attempt → no error.
|
||||
$wo->update(['status' => 'done', 'closed_at' => today(), 'pay_status' => 'paid']);
|
||||
$this->assertEquals('done', $wo->fresh()->status);
|
||||
|
||||
// 9. The Deal can now be marked won.
|
||||
$deal->update(['stage' => 'done', 'price' => $wo->total, 'won_at' => now()]);
|
||||
$this->assertEquals('done', $deal->fresh()->stage);
|
||||
}
|
||||
|
||||
public function test_lead_status_transitions_persist(): void
|
||||
{
|
||||
$this->bootTenant();
|
||||
$lead = Lead::create(['name' => 'X', 'phone' => '+1', 'status' => 'new']);
|
||||
|
||||
foreach (['contacted', 'won', 'lost'] as $st) {
|
||||
$lead->update(['status' => $st]);
|
||||
$this->assertEquals($st, $lead->fresh()->status);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_payment_negative_amount_not_silently_increases_balance(): void
|
||||
{
|
||||
$ctx = $this->bootTenant();
|
||||
$client = Client::create(['name' => 'X', 'phone' => '+1', 'type' => 'individual', 'status' => 'active']);
|
||||
$vehicle = Vehicle::create(['client_id' => $client->id, 'make' => 'X', 'model' => 'Y', 'plate' => 'A1']);
|
||||
$wo = WorkOrder::create([
|
||||
'number' => WorkOrder::generateNumber($ctx['company']->id),
|
||||
'client_id' => $client->id, 'vehicle_id' => $vehicle->id,
|
||||
'opened_at' => today(), 'status' => 'in_work',
|
||||
]);
|
||||
WorkOrderWork::create([
|
||||
'work_order_id' => $wo->id, 'name' => 'X',
|
||||
'hours' => 1, 'price_per_hour' => 100, 'status' => 'todo',
|
||||
]);
|
||||
$wo->refresh();
|
||||
$this->assertEquals(100.0, $wo->balanceDue());
|
||||
|
||||
// A negative payment (refund) is supported and should INCREASE balance back.
|
||||
Payment::create([
|
||||
'client_id' => $client->id, 'work_order_id' => $wo->id,
|
||||
'paid_at' => today(), 'amount' => -50, 'method' => 'cash',
|
||||
]);
|
||||
$wo->refresh();
|
||||
$this->assertEquals(-50.0, $wo->paidAmount(), 'negative paidAmount means refund');
|
||||
$this->assertEquals(150.0, $wo->balanceDue(), 'refund of 50 adds to outstanding: 100 − (−50) = 150');
|
||||
}
|
||||
|
||||
private function bootTenant(): array
|
||||
{
|
||||
$plan = Plan::firstOrCreate(['slug' => 'test'], ['name' => 'T', 'price' => 0, 'features' => []]);
|
||||
$company = Company::create([
|
||||
'plan_id' => $plan->id, 'slug' => 'crm-' . uniqid(),
|
||||
'name' => 'CRM Co', 'status' => 'active',
|
||||
]);
|
||||
app(TenantManager::class)->setCurrent($company);
|
||||
return compact('company');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
<?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\Services\CsvImportExport;
|
||||
use App\Tenancy\TenantManager;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class CsvImportExportTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_import_clients_creates_records_with_correct_fields(): void
|
||||
{
|
||||
$this->bootTenant();
|
||||
|
||||
$csv = $this->makeCsv(CsvImportExport::CLIENT_COLUMNS, [
|
||||
['Ion Pop', '+37369000001', '', 'ion@example.com', '', 'individual', 'active', 'site', '', '0', '0', ''],
|
||||
['SRL Auto', '+37322777888', '', '', 'SRL Auto', 'company', 'active', '', '', '5', '0', ''],
|
||||
]);
|
||||
|
||||
$r = app(CsvImportExport::class)->importClients($csv);
|
||||
@unlink($csv);
|
||||
|
||||
$this->assertEquals(2, $r['imported']);
|
||||
$this->assertEquals(0, $r['skipped']);
|
||||
$this->assertEquals(2, Client::count());
|
||||
$this->assertEquals('Ion Pop', Client::where('phone', '+37369000001')->first()->name);
|
||||
}
|
||||
|
||||
public function test_import_clients_skips_duplicate_phone(): void
|
||||
{
|
||||
$this->bootTenant();
|
||||
Client::create([
|
||||
'name' => 'Existing', 'phone' => '+37369000002',
|
||||
'type' => 'individual', 'status' => 'active',
|
||||
]);
|
||||
|
||||
$csv = $this->makeCsv(CsvImportExport::CLIENT_COLUMNS, [
|
||||
['New Name', '+37369000002', '', '', '', 'individual', 'active', '', '', '0', '0', ''],
|
||||
]);
|
||||
$r = app(CsvImportExport::class)->importClients($csv);
|
||||
@unlink($csv);
|
||||
|
||||
$this->assertEquals(0, $r['imported']);
|
||||
$this->assertEquals(1, $r['skipped']);
|
||||
$this->assertEquals('Existing', Client::first()->name);
|
||||
}
|
||||
|
||||
public function test_import_vehicles_uses_make_column_correctly(): void
|
||||
{
|
||||
// This guards a real bug: the service used to write `brand`, but the
|
||||
// vehicles table column is `make`. The import must persist `make`.
|
||||
$ctx = $this->bootTenant();
|
||||
Client::create([
|
||||
'name' => 'Owner', 'phone' => '+37399100200',
|
||||
'type' => 'individual', 'status' => 'active',
|
||||
]);
|
||||
|
||||
$csv = $this->makeCsv(CsvImportExport::VEHICLE_COLUMNS, [
|
||||
// plate, vin, brand(=make), model, year, engine, gearbox, fuel, mileage, color, notes, client_phone
|
||||
['ABC-001', 'VIN1234567890ABCD', 'BMW', 'X5', '2019', '3.0i', 'AT', 'Benzină', '85000', 'negru', '', '+37399100200'],
|
||||
]);
|
||||
|
||||
$r = app(CsvImportExport::class)->importVehicles($csv);
|
||||
@unlink($csv);
|
||||
|
||||
$this->assertEquals(1, $r['imported'], 'one vehicle should be imported');
|
||||
|
||||
$v = Vehicle::first();
|
||||
$this->assertNotNull($v, 'vehicle row exists');
|
||||
$this->assertEquals('BMW', $v->make, 'make column populated from CSV brand header');
|
||||
$this->assertEquals('X5', $v->model);
|
||||
$this->assertEquals(2019, $v->year);
|
||||
}
|
||||
|
||||
public function test_export_vehicles_emits_make_value(): void
|
||||
{
|
||||
$this->bootTenant();
|
||||
$client = Client::create([
|
||||
'name' => 'X', 'phone' => '+37377111222',
|
||||
'type' => 'individual', 'status' => 'active',
|
||||
]);
|
||||
Vehicle::create([
|
||||
'client_id' => $client->id,
|
||||
'plate' => 'CSV-001', 'make' => 'BMW', 'model' => 'M3',
|
||||
]);
|
||||
|
||||
ob_start();
|
||||
$resp = app(CsvImportExport::class)->exportVehicles();
|
||||
$resp->sendContent();
|
||||
$csv = ob_get_clean();
|
||||
|
||||
$this->assertStringContainsString('BMW', $csv, 'exported CSV must include the make value');
|
||||
$this->assertStringContainsString('CSV-001', $csv);
|
||||
$this->assertStringContainsString('M3', $csv);
|
||||
}
|
||||
|
||||
private function bootTenant(): array
|
||||
{
|
||||
$plan = Plan::firstOrCreate(['slug' => 'test'], ['name' => 'T', 'price' => 0, 'features' => []]);
|
||||
$company = Company::create([
|
||||
'plan_id' => $plan->id, 'slug' => 'csv-' . uniqid(),
|
||||
'name' => 'CSV Co', 'status' => 'active',
|
||||
]);
|
||||
app(TenantManager::class)->setCurrent($company);
|
||||
return compact('company');
|
||||
}
|
||||
|
||||
private function makeCsv(array $headers, array $rows): string
|
||||
{
|
||||
$path = tempnam(sys_get_temp_dir(), 'csv') . '.csv';
|
||||
$fh = fopen($path, 'w');
|
||||
fputs($fh, "\xEF\xBB\xBF"); // BOM
|
||||
fputcsv($fh, $headers);
|
||||
foreach ($rows as $row) fputcsv($fh, $row);
|
||||
fclose($fh);
|
||||
return $path;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Audit;
|
||||
|
||||
use App\Mail\WorkOrderReadyMail;
|
||||
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\Services\NotificationDispatcher;
|
||||
use App\Tenancy\TenantManager;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* The multi-channel fallback is the most subtle integration in the system:
|
||||
* Telegram wins if the client has a chat_id + tenant bot configured; otherwise
|
||||
* fall back to email. If neither, do nothing. Tenant flag must respect both.
|
||||
*/
|
||||
class NotificationFallbackTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_telegram_wins_when_chat_id_present(): void
|
||||
{
|
||||
$ctx = $this->bootTenantWithBot();
|
||||
$client = $this->makeClient(['telegram_chat_id' => '12345', 'email' => 'x@example.com']);
|
||||
$wo = $this->makeReadyWo($client, $ctx);
|
||||
|
||||
Mail::fake();
|
||||
Http::fake(['api.telegram.org/*' => Http::response(['ok' => true])]);
|
||||
|
||||
$ok = app(NotificationDispatcher::class)->workOrderReady($wo);
|
||||
|
||||
$this->assertTrue($ok);
|
||||
Http::assertSent(fn ($r) => str_contains($r->url(), 'sendMessage'));
|
||||
Mail::assertNotSent(WorkOrderReadyMail::class);
|
||||
}
|
||||
|
||||
public function test_falls_back_to_email_when_no_chat_id(): void
|
||||
{
|
||||
$ctx = $this->bootTenantWithBot();
|
||||
$client = $this->makeClient(['email' => 'x@example.com']);
|
||||
$wo = $this->makeReadyWo($client, $ctx);
|
||||
|
||||
Mail::fake();
|
||||
Http::fake();
|
||||
$ok = app(NotificationDispatcher::class)->workOrderReady($wo);
|
||||
|
||||
$this->assertTrue($ok);
|
||||
Mail::assertSent(WorkOrderReadyMail::class);
|
||||
Http::assertNothingSent();
|
||||
}
|
||||
|
||||
public function test_returns_false_when_no_channel_available(): void
|
||||
{
|
||||
$ctx = $this->bootTenantWithBot();
|
||||
$client = $this->makeClient([]); // no email, no chat_id
|
||||
$wo = $this->makeReadyWo($client, $ctx);
|
||||
|
||||
Mail::fake();
|
||||
$ok = app(NotificationDispatcher::class)->workOrderReady($wo);
|
||||
|
||||
$this->assertFalse($ok);
|
||||
Mail::assertNothingSent();
|
||||
}
|
||||
|
||||
public function test_tenant_disable_flag_overrides_channels(): void
|
||||
{
|
||||
$ctx = $this->bootTenantWithBot();
|
||||
// Disable the wo_ready notification globally for this tenant.
|
||||
$ctx['company']->update([
|
||||
'settings' => array_merge_recursive((array) $ctx['company']->settings, [
|
||||
'notify' => ['wo_ready' => false],
|
||||
]),
|
||||
]);
|
||||
|
||||
$client = $this->makeClient(['email' => 'x@example.com']);
|
||||
$wo = $this->makeReadyWo($client, $ctx);
|
||||
|
||||
Mail::fake();
|
||||
Http::fake();
|
||||
$ok = app(NotificationDispatcher::class)->workOrderReady($wo);
|
||||
|
||||
$this->assertFalse($ok);
|
||||
Mail::assertNothingSent();
|
||||
}
|
||||
|
||||
private function bootTenantWithBot(): array
|
||||
{
|
||||
$plan = Plan::firstOrCreate(['slug' => 'test'], ['name' => 'T', 'price' => 0, 'features' => []]);
|
||||
$company = Company::create([
|
||||
'plan_id' => $plan->id, 'slug' => 'nf-' . uniqid(),
|
||||
'name' => 'Notify', 'status' => 'active',
|
||||
'settings' => ['telegram' => ['bot_token' => 'FAKE:TOKEN']],
|
||||
]);
|
||||
app(TenantManager::class)->setCurrent($company);
|
||||
return compact('company');
|
||||
}
|
||||
|
||||
private function makeClient(array $attrs): Client
|
||||
{
|
||||
return Client::create(array_merge([
|
||||
'name' => 'C', 'phone' => '+3737' . random_int(1000000, 9999999),
|
||||
'type' => 'individual', 'status' => 'active',
|
||||
], $attrs));
|
||||
}
|
||||
|
||||
private function makeReadyWo(Client $client, array $ctx): WorkOrder
|
||||
{
|
||||
$vehicle = Vehicle::create(['client_id' => $client->id, 'make' => 'X', 'model' => 'Y', 'plate' => 'NF' . random_int(100, 999)]);
|
||||
return WorkOrder::create([
|
||||
'number' => WorkOrder::generateNumber($ctx['company']->id),
|
||||
'client_id' => $client->id, 'vehicle_id' => $vehicle->id,
|
||||
'opened_at' => today(), 'status' => 'ready', 'total' => 500,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Audit;
|
||||
|
||||
use App\Models\Central\Company;
|
||||
use App\Models\Central\Plan;
|
||||
use App\Models\Tenant\Client;
|
||||
use App\Models\Tenant\EmployeeProfile;
|
||||
use App\Models\Tenant\PayrollAdjustment;
|
||||
use App\Models\Tenant\User;
|
||||
use App\Models\Tenant\Vehicle;
|
||||
use App\Models\Tenant\WorkOrder;
|
||||
use App\Models\Tenant\WorkOrderPart;
|
||||
use App\Models\Tenant\WorkOrderWork;
|
||||
use App\Services\PayrollCalculator;
|
||||
use App\Tenancy\TenantManager;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Tests\TestCase;
|
||||
|
||||
class PayrollCalculatorTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_compute_combines_base_works_parts_and_adjustments(): void
|
||||
{
|
||||
Carbon::setTestNow('2026-06-15');
|
||||
$ctx = $this->bootTenant();
|
||||
$user = $this->makeMaster();
|
||||
EmployeeProfile::create([
|
||||
'user_id' => $user->id,
|
||||
'base_salary' => 5000,
|
||||
'works_pct' => 30, // 30% from done work revenue
|
||||
'parts_pct' => 10, // 10% from parts margin
|
||||
]);
|
||||
|
||||
$client = Client::create(['name' => 'C', 'phone' => '+37399100100', 'type' => 'individual', 'status' => 'active']);
|
||||
$vehicle = Vehicle::create(['client_id' => $client->id, 'make' => 'X', 'model' => 'Y', 'plate' => 'PR-1']);
|
||||
$wo = WorkOrder::create([
|
||||
'number' => WorkOrder::generateNumber($ctx['company']->id),
|
||||
'client_id' => $client->id, 'vehicle_id' => $vehicle->id,
|
||||
'opened_at' => today()->subDays(3), 'status' => 'in_work',
|
||||
'master_id' => $user->id,
|
||||
]);
|
||||
|
||||
// A done labor: 2h × 400 = 800 → 30% = 240
|
||||
WorkOrderWork::create([
|
||||
'work_order_id' => $wo->id, 'master_id' => $user->id,
|
||||
'name' => 'X', 'hours' => 2, 'price_per_hour' => 400, 'status' => 'done',
|
||||
]);
|
||||
|
||||
// An installed part: sell 350 - buy 200 = 150 margin × 1 qty → 10% = 15
|
||||
WorkOrderPart::create([
|
||||
'work_order_id' => $wo->id,
|
||||
'name' => 'P', 'qty' => 1, 'sell_price' => 350, 'buy_price' => 200,
|
||||
'status' => 'installed',
|
||||
]);
|
||||
|
||||
// Adjustments for the period
|
||||
PayrollAdjustment::create([
|
||||
'user_id' => $user->id, 'period' => '2026-06',
|
||||
'type' => 'bonus', 'amount' => 500, 'date' => today(),
|
||||
]);
|
||||
PayrollAdjustment::create([
|
||||
'user_id' => $user->id, 'period' => '2026-06',
|
||||
'type' => 'fine', 'amount' => 100, 'date' => today(),
|
||||
]);
|
||||
PayrollAdjustment::create([
|
||||
'user_id' => $user->id, 'period' => '2026-06',
|
||||
'type' => 'advance', 'amount' => 200, 'date' => today(),
|
||||
]);
|
||||
|
||||
$run = app(PayrollCalculator::class)->compute($user->id, '2026-06');
|
||||
|
||||
// base 5000 + works 240 + parts 15 + bonus 500 - fine 100 - advance 200 = 5455
|
||||
$this->assertEquals(5455.0, (float) $run->total);
|
||||
$this->assertEquals(5000.0, (float) $run->base);
|
||||
$this->assertEquals(240.0, (float) $run->works_pct_amount);
|
||||
$this->assertEquals(15.0, (float) $run->parts_pct_amount);
|
||||
|
||||
Carbon::setTestNow();
|
||||
}
|
||||
|
||||
public function test_compute_ignores_other_periods_and_other_users(): void
|
||||
{
|
||||
Carbon::setTestNow('2026-06-15');
|
||||
$ctx = $this->bootTenant();
|
||||
$me = $this->makeMaster();
|
||||
$other = $this->makeMaster();
|
||||
EmployeeProfile::create(['user_id' => $me->id, 'base_salary' => 1000, 'works_pct' => 100, 'parts_pct' => 0]);
|
||||
|
||||
$c = Client::create(['name' => 'X', 'phone' => '+37300000000', 'type' => 'individual', 'status' => 'active']);
|
||||
$v = Vehicle::create(['client_id' => $c->id, 'make' => 'X', 'model' => 'Y', 'plate' => 'NOISE']);
|
||||
$wo = WorkOrder::create([
|
||||
'number' => WorkOrder::generateNumber($ctx['company']->id),
|
||||
'client_id' => $c->id, 'vehicle_id' => $v->id,
|
||||
'opened_at' => today(), 'status' => 'in_work',
|
||||
]);
|
||||
|
||||
// Another master's work — must be excluded.
|
||||
WorkOrderWork::create([
|
||||
'work_order_id' => $wo->id, 'master_id' => $other->id,
|
||||
'name' => 'X', 'hours' => 5, 'price_per_hour' => 400, 'status' => 'done',
|
||||
]);
|
||||
// Adjustment for a different period — must be excluded.
|
||||
PayrollAdjustment::create([
|
||||
'user_id' => $me->id, 'period' => '2026-05',
|
||||
'type' => 'bonus', 'amount' => 9999, 'date' => today()->subMonth(),
|
||||
]);
|
||||
|
||||
$run = app(PayrollCalculator::class)->compute($me->id, '2026-06');
|
||||
$this->assertEquals(1000.0, (float) $run->total, 'only base — no own works, no current-period adjustments');
|
||||
Carbon::setTestNow();
|
||||
}
|
||||
|
||||
private function bootTenant(): array
|
||||
{
|
||||
$plan = Plan::firstOrCreate(['slug' => 'test'], ['name' => 'T', 'price' => 0, 'features' => []]);
|
||||
$company = Company::create([
|
||||
'plan_id' => $plan->id, 'slug' => 'pr-' . uniqid(),
|
||||
'name' => 'Payroll Co', 'status' => 'active',
|
||||
]);
|
||||
app(TenantManager::class)->setCurrent($company);
|
||||
return compact('company');
|
||||
}
|
||||
|
||||
private function makeMaster(): User
|
||||
{
|
||||
return User::create([
|
||||
'name' => 'M', 'email' => 'm-' . uniqid() . '@example.com',
|
||||
'password' => bcrypt('x'), 'role' => 'master', 'status' => 'active',
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Audit;
|
||||
|
||||
use App\Models\Central\Company;
|
||||
use App\Models\Central\Plan;
|
||||
use App\Tenancy\TenantManager;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* Settings JSON has grown ~25 keys across 8 sections. Verify the round-trip
|
||||
* shape is correct: every setting our pages write must come back when read.
|
||||
*/
|
||||
class SettingsPersistenceTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_settings_round_trip_full_shape(): void
|
||||
{
|
||||
$plan = Plan::firstOrCreate(['slug' => 'test'], ['name' => 'T', 'price' => 0, 'features' => []]);
|
||||
$payload = [
|
||||
'currency' => 'MDL',
|
||||
'language' => 'ro',
|
||||
'theme_color' => '#ff8800',
|
||||
'labor_rate' => 450.5,
|
||||
'services' => ['Diagnoză', 'Frâne'],
|
||||
'cars' => ['BMW', 'Audi'],
|
||||
'notify' => [
|
||||
'wo_ready' => true,
|
||||
'payment' => false,
|
||||
'appointment' => true,
|
||||
'reminder' => true,
|
||||
],
|
||||
'telegram' => ['bot_token' => 'secret-token'],
|
||||
'reminder' => ['after_days' => 400, 'cooldown_days' => 45],
|
||||
'shop' => [
|
||||
'enabled' => true,
|
||||
'delivery_methods' => ['pickup', 'courier'],
|
||||
'delivery_fee' => 50,
|
||||
'free_delivery_over' => 1000,
|
||||
],
|
||||
'ai' => [
|
||||
'default_provider' => 'claude',
|
||||
'claude_key' => 'sk-ant-xxx',
|
||||
'gpt_key' => null,
|
||||
'gemini_key' => null,
|
||||
'models' => [
|
||||
'claude' => 'claude-opus-4-7',
|
||||
'gpt' => 'gpt-4o',
|
||||
'gemini' => 'gemini-1.5-pro',
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
$company = Company::create([
|
||||
'plan_id' => $plan->id, 'slug' => 'st-' . uniqid(),
|
||||
'name' => 'Settings Co', 'status' => 'active',
|
||||
'settings' => $payload,
|
||||
]);
|
||||
app(TenantManager::class)->setCurrent($company);
|
||||
|
||||
$fresh = Company::withoutGlobalScopes()->find($company->id);
|
||||
$this->assertEquals($payload, $fresh->settings);
|
||||
|
||||
// Spot-check critical values via data_get (the way services read them).
|
||||
$this->assertEquals('MDL', data_get($fresh->settings, 'currency'));
|
||||
$this->assertTrue(data_get($fresh->settings, 'shop.enabled'));
|
||||
$this->assertEquals(450.5, data_get($fresh->settings, 'labor_rate'));
|
||||
$this->assertEquals('claude-opus-4-7', data_get($fresh->settings, 'ai.models.claude'));
|
||||
$this->assertEquals(45, data_get($fresh->settings, 'reminder.cooldown_days'));
|
||||
}
|
||||
|
||||
public function test_partial_update_preserves_other_keys(): void
|
||||
{
|
||||
$plan = Plan::firstOrCreate(['slug' => 'test'], ['name' => 'T', 'price' => 0, 'features' => []]);
|
||||
$company = Company::create([
|
||||
'plan_id' => $plan->id, 'slug' => 'pu-' . uniqid(),
|
||||
'name' => 'Partial', 'status' => 'active',
|
||||
'settings' => [
|
||||
'currency' => 'MDL',
|
||||
'shop' => ['enabled' => true, 'delivery_fee' => 50],
|
||||
'ai' => ['claude_key' => 'sk-xxx'],
|
||||
],
|
||||
]);
|
||||
|
||||
// Update only the shop subsection (simulating an isolated UI save).
|
||||
$company->update([
|
||||
'settings' => array_replace_recursive((array) $company->settings, [
|
||||
'shop' => ['delivery_fee' => 100],
|
||||
]),
|
||||
]);
|
||||
|
||||
$fresh = Company::withoutGlobalScopes()->find($company->id);
|
||||
$this->assertEquals('MDL', data_get($fresh->settings, 'currency'), 'currency preserved');
|
||||
$this->assertTrue(data_get($fresh->settings, 'shop.enabled'), 'shop.enabled preserved');
|
||||
$this->assertEquals(100, data_get($fresh->settings, 'shop.delivery_fee'), 'shop.delivery_fee updated');
|
||||
$this->assertEquals('sk-xxx', data_get($fresh->settings, 'ai.claude_key'), 'ai key untouched');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Audit;
|
||||
|
||||
use App\Mail\ShopOrderConfirmationMail;
|
||||
use App\Models\Central\Company;
|
||||
use App\Models\Central\Plan;
|
||||
use App\Models\Tenant\OnlineOrder;
|
||||
use App\Models\Tenant\Part;
|
||||
use App\Models\Tenant\ShopCustomer;
|
||||
use App\Models\Tenant\Warehouse;
|
||||
use App\Models\Tenant\WarehouseEvent;
|
||||
use App\Services\Warehouse\WarehouseService;
|
||||
use App\Tenancy\TenantManager;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* End-to-end shop journey: customer registers → adds to cart → checks out →
|
||||
* receives confirmation email → admin fulfills → WarehouseService.issue runs →
|
||||
* stock decreases → tracking page reachable.
|
||||
*
|
||||
* Exercises ShopAuthController, ShopController, OnlineOrder, OnlineOrderItem,
|
||||
* ShopCustomer, ShopOrderNotifier, WarehouseService, and the tracking route.
|
||||
*/
|
||||
class ShopJourneyE2ETest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_full_shop_journey_registered_customer(): void
|
||||
{
|
||||
$ctx = $this->bootShop('e2e-shop');
|
||||
$svc = app(WarehouseService::class);
|
||||
|
||||
// Stock a published part: 20 in inventory.
|
||||
$part = Part::create([
|
||||
'name' => 'Plăcuțe BMW', 'article' => 'BMW-001', 'brand' => 'TRW',
|
||||
'sell_price' => 250, 'buy_price' => 150,
|
||||
'qty' => 0, 'unit' => 'buc',
|
||||
'is_active' => true, 'is_published' => true,
|
||||
]);
|
||||
$svc->receive($part, 20, 150);
|
||||
$this->assertEquals(20.0, (float) $part->fresh()->qty);
|
||||
|
||||
Mail::fake();
|
||||
|
||||
$base = 'http://e2e-shop.service.mir.md';
|
||||
|
||||
// 1. Register.
|
||||
$this->post("$base/shop/register", [
|
||||
'name' => 'Tester', 'phone' => '+37377012345',
|
||||
'email' => 'tester@example.com',
|
||||
'password' => 'secret123', 'password_confirmation' => 'secret123',
|
||||
])->assertRedirect('/shop/account');
|
||||
|
||||
$customer = ShopCustomer::where('phone', '+37377012345')->first();
|
||||
$this->assertNotNull($customer);
|
||||
$this->assertTrue(Auth::guard('shop')->check());
|
||||
|
||||
// 2. Browse catalog (anonymous-safe, but we're logged in).
|
||||
$r = $this->get("$base/shop");
|
||||
$r->assertOk();
|
||||
$r->assertSee('Plăcuțe BMW');
|
||||
|
||||
// 3. Add to cart.
|
||||
$this->post("$base/shop/part/{$part->id}/add", ['qty' => 2])
|
||||
->assertRedirect('/shop/cart');
|
||||
|
||||
$this->get("$base/shop/cart")->assertOk()->assertSee('500.00');
|
||||
|
||||
// 4. Checkout.
|
||||
$this->get("$base/shop/checkout")
|
||||
->assertOk()
|
||||
->assertSee('Tester', false) // prefilled from logged-in customer
|
||||
->assertSee('+37377012345');
|
||||
|
||||
$this->post("$base/shop/checkout", [
|
||||
'customer_name' => 'Tester',
|
||||
'customer_phone' => '+37377012345',
|
||||
'customer_email' => 'tester@example.com',
|
||||
'delivery_method' => 'pickup',
|
||||
])->assertRedirect();
|
||||
|
||||
$order = OnlineOrder::first();
|
||||
$this->assertNotNull($order);
|
||||
$this->assertEquals($customer->id, $order->shop_customer_id);
|
||||
$this->assertEquals(500.0, (float) $order->total);
|
||||
|
||||
// 5. Confirmation email sent to customer.
|
||||
Mail::assertSent(ShopOrderConfirmationMail::class, fn ($m) => $m->order->id === $order->id);
|
||||
|
||||
// 6. Tracking page reachable by token (NO auth).
|
||||
Auth::guard('shop')->logout();
|
||||
$r = $this->get($order->trackingUrl());
|
||||
$r->assertOk();
|
||||
$r->assertSee('#' . $order->number);
|
||||
$r->assertSee('Plăcuțe BMW');
|
||||
|
||||
// 7. Admin (back in tenant context) fulfills via service::issue.
|
||||
$svc->issue($part, 2, null, $order, 'shop fulfill');
|
||||
$part->refresh();
|
||||
$this->assertEquals(18.0, (float) $part->qty, 'stock decreased by 2');
|
||||
|
||||
// 8. The warehouse event records the issue with ref pointing at the order.
|
||||
$event = WarehouseEvent::where('part_id', $part->id)
|
||||
->where('type', 'issue')
|
||||
->latest('id')->first();
|
||||
$this->assertNotNull($event);
|
||||
$this->assertEquals(-2.0, (float) $event->qty_delta);
|
||||
$this->assertStringContainsString('OnlineOrder', (string) $event->ref_type);
|
||||
}
|
||||
|
||||
public function test_guest_checkout_still_works_without_account(): void
|
||||
{
|
||||
$this->bootShop('guest');
|
||||
$part = Part::create([
|
||||
'name' => 'Filtru', 'sell_price' => 50, 'qty' => 5, 'unit' => 'buc',
|
||||
'is_active' => true, 'is_published' => true,
|
||||
]);
|
||||
|
||||
$base = 'http://guest.service.mir.md/shop';
|
||||
|
||||
$this->post("$base/part/{$part->id}/add", ['qty' => 1])->assertRedirect();
|
||||
$this->post("$base/checkout", [
|
||||
'customer_name' => 'Guest', 'customer_phone' => '+37399999999',
|
||||
'delivery_method' => 'pickup',
|
||||
])->assertRedirect();
|
||||
|
||||
$order = OnlineOrder::first();
|
||||
$this->assertNotNull($order);
|
||||
$this->assertNull($order->shop_customer_id);
|
||||
$this->assertEquals(50.0, (float) $order->total);
|
||||
}
|
||||
|
||||
private function bootShop(string $slug): array
|
||||
{
|
||||
$plan = Plan::firstOrCreate(['slug' => 'test'], ['name' => 'T', 'price' => 0, 'features' => []]);
|
||||
$company = Company::create([
|
||||
'plan_id' => $plan->id, 'slug' => $slug,
|
||||
'name' => ucfirst($slug), 'status' => 'active',
|
||||
'settings' => ['shop' => ['enabled' => true, 'delivery_methods' => ['pickup']]],
|
||||
]);
|
||||
app(TenantManager::class)->setCurrent($company);
|
||||
|
||||
$wh = Warehouse::create([
|
||||
'code' => 'MAIN', 'name' => 'Main',
|
||||
'is_default' => true, 'is_active' => true,
|
||||
]);
|
||||
$company->forceFill(['default_warehouse_id' => $wh->id])->saveQuietly();
|
||||
return compact('company');
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
<?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\WorkOrderPart;
|
||||
use App\Models\Tenant\WorkOrderWork;
|
||||
use App\Services\WorkOrderPdfService;
|
||||
use App\Tenancy\TenantManager;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class WorkOrderPdfServiceTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_pdf_generates_with_works_and_parts(): void
|
||||
{
|
||||
$ctx = $this->bootTenant();
|
||||
$client = Client::create(['name' => 'PdfClient', 'phone' => '+37300100200', 'type' => 'individual', 'status' => 'active']);
|
||||
$vehicle = Vehicle::create([
|
||||
'client_id' => $client->id, 'make' => 'Audi', 'model' => 'A4', 'plate' => 'PDF-001',
|
||||
]);
|
||||
$wo = WorkOrder::create([
|
||||
'number' => WorkOrder::generateNumber($ctx['company']->id),
|
||||
'client_id' => $client->id, 'vehicle_id' => $vehicle->id,
|
||||
'opened_at' => today(), 'status' => 'in_work',
|
||||
'complaint' => 'Zgomot la frânare',
|
||||
]);
|
||||
WorkOrderWork::create([
|
||||
'work_order_id' => $wo->id, 'name' => 'Schimb plăcuțe',
|
||||
'hours' => 1.5, 'price_per_hour' => 400, 'status' => 'done',
|
||||
]);
|
||||
WorkOrderPart::create([
|
||||
'work_order_id' => $wo->id, 'name' => 'Plăcuțe TRW',
|
||||
'qty' => 1, 'buy_price' => 200, 'sell_price' => 350,
|
||||
'status' => 'installed',
|
||||
]);
|
||||
|
||||
$svc = app(WorkOrderPdfService::class);
|
||||
$pdf = $svc->generate($wo);
|
||||
$output = $pdf->output();
|
||||
|
||||
$this->assertStringStartsWith('%PDF', $output, 'binary starts with PDF magic');
|
||||
$this->assertGreaterThan(2000, strlen($output), 'non-trivial PDF size');
|
||||
|
||||
$filename = $svc->filename($wo);
|
||||
$this->assertStringContainsString($wo->number, $filename);
|
||||
$this->assertStringEndsWith('.pdf', $filename);
|
||||
}
|
||||
|
||||
public function test_pdf_handles_wo_with_no_lines(): void
|
||||
{
|
||||
$ctx = $this->bootTenant();
|
||||
$client = Client::create(['name' => 'X', 'phone' => '+37399000000', 'type' => 'individual', 'status' => 'active']);
|
||||
$vehicle = Vehicle::create(['client_id' => $client->id, 'make' => 'X', 'model' => 'Y', 'plate' => 'EMPTY-1']);
|
||||
$wo = WorkOrder::create([
|
||||
'number' => WorkOrder::generateNumber($ctx['company']->id),
|
||||
'client_id' => $client->id, 'vehicle_id' => $vehicle->id,
|
||||
'opened_at' => today(), 'status' => 'new',
|
||||
]);
|
||||
|
||||
$pdf = app(WorkOrderPdfService::class)->generate($wo);
|
||||
$this->assertStringStartsWith('%PDF', $pdf->output());
|
||||
}
|
||||
|
||||
private function bootTenant(): array
|
||||
{
|
||||
$plan = Plan::firstOrCreate(['slug' => 'test'], ['name' => 'T', 'price' => 0, 'features' => []]);
|
||||
$company = Company::create([
|
||||
'plan_id' => $plan->id, 'slug' => 'pdf-' . uniqid(),
|
||||
'name' => 'PDF Co', 'status' => 'active',
|
||||
]);
|
||||
app(TenantManager::class)->setCurrent($company);
|
||||
return compact('company');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Audit;
|
||||
|
||||
use App\Models\Central\Company;
|
||||
use App\Models\Central\Plan;
|
||||
use App\Models\Tenant\Client;
|
||||
use App\Models\Tenant\Part;
|
||||
use App\Models\Tenant\SubcontractJob;
|
||||
use App\Models\Tenant\Vehicle;
|
||||
use App\Models\Tenant\Warehouse;
|
||||
use App\Models\Tenant\WorkOrder;
|
||||
use App\Models\Tenant\WorkOrderPart;
|
||||
use App\Models\Tenant\WorkOrderWork;
|
||||
use App\Services\Warehouse\WarehouseService;
|
||||
use App\Tenancy\TenantManager;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* The most fragile invariant in the system: WorkOrder.total. Every
|
||||
* line type (works + parts + subcontract) feeds into it, plus discount,
|
||||
* minus excluded statuses. If recalcTotal forgets ONE source, money disappears.
|
||||
*/
|
||||
class WorkOrderTotalsTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_total_aggregates_all_line_types_and_applies_discount(): void
|
||||
{
|
||||
$ctx = $this->bootstrap();
|
||||
$wo = $this->makeWo($ctx, discountPct: 10);
|
||||
|
||||
// Labor: 2h × 400 = 800
|
||||
WorkOrderWork::create([
|
||||
'work_order_id' => $wo->id, 'name' => 'Manoperă',
|
||||
'hours' => 2, 'price_per_hour' => 400, 'status' => 'todo',
|
||||
]);
|
||||
|
||||
// Part: 3 × 150 = 450
|
||||
$part = Part::create([
|
||||
'name' => 'Filtru', 'sell_price' => 150, 'buy_price' => 100,
|
||||
'qty' => 10, 'unit' => 'buc', 'is_active' => true,
|
||||
]);
|
||||
WorkOrderPart::create([
|
||||
'work_order_id' => $wo->id, 'part_id' => $part->id,
|
||||
'name' => $part->name, 'qty' => 3,
|
||||
'buy_price' => 100, 'sell_price' => 150,
|
||||
'status' => 'needed',
|
||||
]);
|
||||
|
||||
// Subcontract: cost 500 × 1.20 markup = 600 client price
|
||||
SubcontractJob::create([
|
||||
'work_order_id' => $wo->id, 'category' => 'Turbo',
|
||||
'cost' => 500, 'markup_pct' => 20, 'status' => 'sent',
|
||||
]);
|
||||
|
||||
$wo->refresh();
|
||||
// Subtotal: 800 + 450 + 600 = 1850. With 10% discount → 1665.
|
||||
$this->assertEquals(1665.0, (float) $wo->total);
|
||||
}
|
||||
|
||||
public function test_cancelled_subcontract_excluded_from_total(): void
|
||||
{
|
||||
$ctx = $this->bootstrap();
|
||||
$wo = $this->makeWo($ctx);
|
||||
|
||||
$sub = SubcontractJob::create([
|
||||
'work_order_id' => $wo->id, 'cost' => 1000, 'markup_pct' => 50, 'status' => 'sent',
|
||||
]);
|
||||
$wo->refresh();
|
||||
$this->assertEquals(1500.0, (float) $wo->total);
|
||||
|
||||
$sub->update(['status' => 'cancelled']);
|
||||
$wo->refresh();
|
||||
$this->assertEquals(0.0, (float) $wo->total);
|
||||
}
|
||||
|
||||
public function test_deleting_any_line_type_recalcs_total(): void
|
||||
{
|
||||
$ctx = $this->bootstrap();
|
||||
$wo = $this->makeWo($ctx);
|
||||
|
||||
$w = WorkOrderWork::create([
|
||||
'work_order_id' => $wo->id, 'name' => 'X',
|
||||
'hours' => 1, 'price_per_hour' => 100, 'status' => 'todo',
|
||||
]);
|
||||
$wo->refresh();
|
||||
$this->assertEquals(100.0, (float) $wo->total);
|
||||
|
||||
$w->delete();
|
||||
$wo->refresh();
|
||||
$this->assertEquals(0.0, (float) $wo->total);
|
||||
}
|
||||
|
||||
public function test_status_done_consumes_part_reservations(): void
|
||||
{
|
||||
$ctx = $this->bootstrap();
|
||||
$svc = app(WarehouseService::class);
|
||||
|
||||
// Stock the warehouse with one batch of 10.
|
||||
$part = Part::create([
|
||||
'name' => 'Plăcuțe', 'sell_price' => 200, 'buy_price' => 100,
|
||||
'qty' => 0, 'unit' => 'buc', 'is_active' => true,
|
||||
]);
|
||||
$svc->receive($part, 10, 100);
|
||||
$this->assertEquals(10.0, (float) $part->fresh()->qty);
|
||||
|
||||
$wo = $this->makeWo($ctx);
|
||||
WorkOrderPart::create([
|
||||
'work_order_id' => $wo->id, 'part_id' => $part->id,
|
||||
'name' => $part->name, 'qty' => 4,
|
||||
'buy_price' => 100, 'sell_price' => 200,
|
||||
'status' => 'needed',
|
||||
]);
|
||||
|
||||
// Before close: stock still 10, qty_reserved 4.
|
||||
$part->refresh();
|
||||
$this->assertEquals(10.0, (float) $part->qty);
|
||||
$this->assertEquals(4.0, (float) $part->qty_reserved);
|
||||
|
||||
// Close WO → reservations become consume events.
|
||||
$wo->update(['status' => 'done']);
|
||||
$part->refresh();
|
||||
$this->assertEquals(6.0, (float) $part->qty);
|
||||
$this->assertEquals(0.0, (float) $part->qty_reserved);
|
||||
}
|
||||
|
||||
public function test_cancelled_wo_releases_reservations(): void
|
||||
{
|
||||
$ctx = $this->bootstrap();
|
||||
$svc = app(WarehouseService::class);
|
||||
$part = Part::create([
|
||||
'name' => 'X', 'sell_price' => 10, 'buy_price' => 5,
|
||||
'qty' => 0, 'unit' => 'buc', 'is_active' => true,
|
||||
]);
|
||||
$svc->receive($part, 5, 5);
|
||||
|
||||
$wo = $this->makeWo($ctx);
|
||||
WorkOrderPart::create([
|
||||
'work_order_id' => $wo->id, 'part_id' => $part->id,
|
||||
'name' => 'X', 'qty' => 2,
|
||||
'buy_price' => 5, 'sell_price' => 10,
|
||||
'status' => 'needed',
|
||||
]);
|
||||
$this->assertEquals(2.0, (float) $part->fresh()->qty_reserved);
|
||||
|
||||
$wo->update(['status' => 'cancelled']);
|
||||
$this->assertEquals(0.0, (float) $part->fresh()->qty_reserved);
|
||||
$this->assertEquals(5.0, (float) $part->fresh()->qty, 'stock untouched after cancel');
|
||||
}
|
||||
|
||||
private function bootstrap(): array
|
||||
{
|
||||
$plan = Plan::firstOrCreate(['slug' => 'test'], ['name' => 'T', 'price' => 0, 'features' => []]);
|
||||
$company = Company::create([
|
||||
'plan_id' => $plan->id, 'slug' => 'wot-' . uniqid(),
|
||||
'name' => 'WO Totals', 'status' => 'active',
|
||||
]);
|
||||
app(TenantManager::class)->setCurrent($company);
|
||||
|
||||
// Ensure warehouse exists so WarehouseService::receive works.
|
||||
$wh = Warehouse::create([
|
||||
'code' => 'MAIN', 'name' => 'Main',
|
||||
'is_default' => true, 'is_active' => true,
|
||||
]);
|
||||
$company->forceFill(['default_warehouse_id' => $wh->id])->saveQuietly();
|
||||
|
||||
return compact('company');
|
||||
}
|
||||
|
||||
private function makeWo(array $ctx, float $discountPct = 0): WorkOrder
|
||||
{
|
||||
$client = Client::create([
|
||||
'name' => 'C', 'phone' => '+3736' . random_int(1000000, 9999999),
|
||||
'type' => 'individual', 'status' => 'active',
|
||||
]);
|
||||
$vehicle = Vehicle::create([
|
||||
'client_id' => $client->id, 'make' => 'X', 'model' => 'Y',
|
||||
'plate' => 'WO' . random_int(100, 999),
|
||||
]);
|
||||
return WorkOrder::create([
|
||||
'number' => WorkOrder::generateNumber($ctx['company']->id),
|
||||
'client_id' => $client->id, 'vehicle_id' => $vehicle->id,
|
||||
'opened_at' => today(), 'status' => 'in_work',
|
||||
'discount_pct' => $discountPct,
|
||||
]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user