Files
autocrm/tests/Feature/Audit/NotificationFallbackTest.php
Vasyka 0620635abb 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>
2026-06-03 07:05:46 +00:00

122 lines
4.1 KiB
PHP

<?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,
]);
}
}