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,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');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user