Deploy 1: i18n + Notifications + Global Search + Tests
- SetLocale middleware (ro/ru/en, session-first, user-persisted)
- Lang switcher in topbar (Filament render hook USER_MENU_BEFORE)
- POST /locale/{lang} route persists to user.locale + session
- Database notifications enabled on tenant panel (30s polling)
- GlobalSearch (Cmd+K / Ctrl+K) on Client, Vehicle, WorkOrder, Lead, Part
- Tests: TenantIsolation (4), AuthFlow (2), WorkOrderCalc (3), MarkupRule (3)
This commit is contained in:
@@ -0,0 +1,77 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\Central\Company;
|
||||
use App\Models\Central\Plan;
|
||||
use App\Models\Tenant\User;
|
||||
use App\Tenancy\TenantManager;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* User-ul tenant A NU trebuie să poată se logheze pe subdomain-ul tenant B.
|
||||
* Garanție 1-user-1-tenant.
|
||||
*/
|
||||
class AuthFlowTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_user_cannot_login_on_wrong_subdomain(): void
|
||||
{
|
||||
$plan = Plan::create([
|
||||
'name' => 'Test', 'slug' => 'test', 'price' => 0, 'features' => [],
|
||||
]);
|
||||
$companyA = Company::create([
|
||||
'plan_id' => $plan->id,
|
||||
'slug' => 'company-a-' . uniqid(),
|
||||
'name' => 'A', 'status' => 'active',
|
||||
]);
|
||||
$companyB = Company::create([
|
||||
'plan_id' => $plan->id,
|
||||
'slug' => 'company-b-' . uniqid(),
|
||||
'name' => 'B', 'status' => 'active',
|
||||
]);
|
||||
|
||||
app(TenantManager::class)->setCurrent($companyA);
|
||||
$userA = User::create([
|
||||
'company_id' => $companyA->id,
|
||||
'name' => 'Alice',
|
||||
'email' => 'alice@a.com',
|
||||
'password' => Hash::make('secret123'),
|
||||
'status' => 'active',
|
||||
]);
|
||||
|
||||
// Switch to company B context — try to attempt() with A's credentials
|
||||
app(TenantManager::class)->setCurrent($companyB);
|
||||
$ok = auth('web')->attempt(['email' => 'alice@a.com', 'password' => 'secret123']);
|
||||
|
||||
$this->assertFalse($ok, 'User from company A authenticated successfully on company B subdomain');
|
||||
}
|
||||
|
||||
public function test_user_can_login_on_own_subdomain(): void
|
||||
{
|
||||
$plan = Plan::create([
|
||||
'name' => 'Test', 'slug' => 'test', 'price' => 0, 'features' => [],
|
||||
]);
|
||||
$company = Company::create([
|
||||
'plan_id' => $plan->id,
|
||||
'slug' => 'mine-' . uniqid(),
|
||||
'name' => 'Mine', 'status' => 'active',
|
||||
]);
|
||||
|
||||
app(TenantManager::class)->setCurrent($company);
|
||||
User::create([
|
||||
'company_id' => $company->id,
|
||||
'name' => 'Bob',
|
||||
'email' => 'bob@mine.com',
|
||||
'password' => Hash::make('pwd12345'),
|
||||
'status' => 'active',
|
||||
]);
|
||||
|
||||
$ok = auth('web')->attempt(['email' => 'bob@mine.com', 'password' => 'pwd12345']);
|
||||
|
||||
$this->assertTrue($ok);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\Central\Company;
|
||||
use App\Models\Central\Plan;
|
||||
use App\Models\Tenant\Client;
|
||||
use App\Models\Tenant\User;
|
||||
use App\Tenancy\TenantManager;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* Verifică că tenant-ul A NU poate vedea/modifica datele tenant-ului B.
|
||||
* Această test e pilonul de securitate al întregii arhitecturi multi-tenant.
|
||||
*/
|
||||
class TenantIsolationTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_tenant_scope_blocks_cross_tenant_reads(): void
|
||||
{
|
||||
[$companyA, $companyB] = $this->makeTwoCompanies();
|
||||
|
||||
// Create client in company A
|
||||
app(TenantManager::class)->setCurrent($companyA);
|
||||
$clientA = Client::create([
|
||||
'name' => 'Alice', 'phone' => '+37300001',
|
||||
'type' => 'individual', 'status' => 'active',
|
||||
]);
|
||||
|
||||
// Switch to company B and try to read
|
||||
app(TenantManager::class)->setCurrent($companyB);
|
||||
$found = Client::find($clientA->id);
|
||||
|
||||
$this->assertNull($found, 'Client from company A leaked into company B query');
|
||||
}
|
||||
|
||||
public function test_tenant_scope_returns_empty_when_no_tenant(): void
|
||||
{
|
||||
[$companyA] = $this->makeTwoCompanies();
|
||||
|
||||
app(TenantManager::class)->setCurrent($companyA);
|
||||
Client::create([
|
||||
'name' => 'X', 'phone' => '+37300002',
|
||||
'type' => 'individual', 'status' => 'active',
|
||||
]);
|
||||
|
||||
// Reset tenant — fail-safe must engage
|
||||
app(TenantManager::class)->setCurrent(null);
|
||||
$count = Client::query()->count();
|
||||
|
||||
$this->assertSame(0, $count, 'TenantScope did not engage WHERE 1=0 fail-safe');
|
||||
}
|
||||
|
||||
public function test_user_email_can_repeat_across_tenants(): void
|
||||
{
|
||||
[$companyA, $companyB] = $this->makeTwoCompanies();
|
||||
|
||||
app(TenantManager::class)->setCurrent($companyA);
|
||||
$userA = User::create([
|
||||
'company_id' => $companyA->id,
|
||||
'name' => 'A', 'email' => 'shared@example.com',
|
||||
'password' => 'pwd', 'status' => 'active',
|
||||
]);
|
||||
|
||||
app(TenantManager::class)->setCurrent($companyB);
|
||||
$userB = User::create([
|
||||
'company_id' => $companyB->id,
|
||||
'name' => 'B', 'email' => 'shared@example.com',
|
||||
'password' => 'pwd', 'status' => 'active',
|
||||
]);
|
||||
|
||||
$this->assertNotEquals($userA->id, $userB->id);
|
||||
$this->assertSame('shared@example.com', $userA->email);
|
||||
$this->assertSame('shared@example.com', $userB->email);
|
||||
}
|
||||
|
||||
public function test_creating_model_auto_fills_company_id(): void
|
||||
{
|
||||
[$companyA] = $this->makeTwoCompanies();
|
||||
|
||||
app(TenantManager::class)->setCurrent($companyA);
|
||||
$client = Client::create([
|
||||
'name' => 'Auto', 'phone' => '+37300003',
|
||||
'type' => 'individual', 'status' => 'active',
|
||||
]);
|
||||
|
||||
$this->assertSame($companyA->id, $client->company_id, 'BelongsToTenant trait did not auto-fill company_id');
|
||||
}
|
||||
|
||||
private function makeTwoCompanies(): array
|
||||
{
|
||||
$plan = Plan::create([
|
||||
'name' => 'Test', 'slug' => 'test', 'price' => 0, 'features' => [],
|
||||
]);
|
||||
|
||||
$a = Company::create([
|
||||
'plan_id' => $plan->id,
|
||||
'slug' => 'aaa-' . uniqid(),
|
||||
'name' => 'AAA Service',
|
||||
'status' => 'active',
|
||||
]);
|
||||
|
||||
$b = Company::create([
|
||||
'plan_id' => $plan->id,
|
||||
'slug' => 'bbb-' . uniqid(),
|
||||
'name' => 'BBB Service',
|
||||
'status' => 'active',
|
||||
]);
|
||||
|
||||
return [$a, $b];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\Central\Company;
|
||||
use App\Models\Central\Plan;
|
||||
use App\Models\Tenant\Client;
|
||||
use App\Models\Tenant\Part;
|
||||
use App\Models\Tenant\Vehicle;
|
||||
use App\Models\Tenant\WorkOrder;
|
||||
use App\Models\Tenant\WorkOrderPart;
|
||||
use App\Models\Tenant\WorkOrderWork;
|
||||
use App\Tenancy\TenantManager;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class WorkOrderCalcTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_total_recalculates_on_work_added(): void
|
||||
{
|
||||
$wo = $this->makeWorkOrder();
|
||||
|
||||
WorkOrderWork::create([
|
||||
'work_order_id' => $wo->id,
|
||||
'name' => 'Schimb plăcuțe',
|
||||
'hours' => 2,
|
||||
'price_per_hour' => 400,
|
||||
'status' => 'todo',
|
||||
]);
|
||||
|
||||
$wo->refresh();
|
||||
$this->assertEquals(800.00, (float) $wo->total);
|
||||
}
|
||||
|
||||
public function test_total_includes_works_plus_parts(): void
|
||||
{
|
||||
$wo = $this->makeWorkOrder();
|
||||
|
||||
WorkOrderWork::create([
|
||||
'work_order_id' => $wo->id,
|
||||
'name' => 'Manoperă',
|
||||
'hours' => 1,
|
||||
'price_per_hour' => 400,
|
||||
'status' => 'todo',
|
||||
]);
|
||||
|
||||
WorkOrderPart::create([
|
||||
'work_order_id' => $wo->id,
|
||||
'name' => 'Filtru',
|
||||
'qty' => 2,
|
||||
'price' => 150,
|
||||
'total' => 300,
|
||||
'status' => 'pending',
|
||||
]);
|
||||
|
||||
$wo->refresh();
|
||||
$this->assertEquals(700.00, (float) $wo->total);
|
||||
}
|
||||
|
||||
public function test_discount_applies_to_total(): void
|
||||
{
|
||||
$wo = $this->makeWorkOrder();
|
||||
$wo->update(['discount_pct' => 10]);
|
||||
|
||||
WorkOrderWork::create([
|
||||
'work_order_id' => $wo->id,
|
||||
'name' => 'X',
|
||||
'hours' => 1,
|
||||
'price_per_hour' => 1000,
|
||||
'status' => 'todo',
|
||||
]);
|
||||
|
||||
$wo->refresh();
|
||||
$this->assertEquals(900.00, (float) $wo->total);
|
||||
}
|
||||
|
||||
private function makeWorkOrder(): WorkOrder
|
||||
{
|
||||
$plan = Plan::create(['name' => 'P', 'slug' => 'p', 'price' => 0, 'features' => []]);
|
||||
$company = Company::create([
|
||||
'plan_id' => $plan->id,
|
||||
'slug' => 'wo-' . uniqid(),
|
||||
'name' => 'WO Test',
|
||||
'status' => 'active',
|
||||
]);
|
||||
app(TenantManager::class)->setCurrent($company);
|
||||
|
||||
$client = Client::create([
|
||||
'name' => 'Test', 'phone' => '+1', 'type' => 'individual', 'status' => 'active',
|
||||
]);
|
||||
$vehicle = Vehicle::create([
|
||||
'client_id' => $client->id,
|
||||
'plate' => 'TST 001',
|
||||
'brand' => 'Test',
|
||||
'model' => 'X',
|
||||
]);
|
||||
|
||||
return WorkOrder::create([
|
||||
'client_id' => $client->id,
|
||||
'vehicle_id' => $vehicle->id,
|
||||
'number' => 'WO-001',
|
||||
'opened_at' => now(),
|
||||
'status' => 'new',
|
||||
'pay_status' => 'unpaid',
|
||||
'discount_pct' => 0,
|
||||
'total' => 0,
|
||||
]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user