Files
portal/app/tests/Feature/Supplier/SupplierLeadDeliveryGuardTest.php
T

208 lines
8.8 KiB
PHP

<?php
declare(strict_types=1);
use App\Jobs\RouteSupplierLeadJob;
use App\Models\Deal;
use App\Models\LeadCharge;
use App\Models\Project;
use App\Models\SupplierLead;
use App\Models\SupplierProject;
use App\Models\Tenant;
use App\Services\Billing\LedgerService;
use App\Services\LeadDistributor;
use App\Services\LeadRouter;
use App\Services\NotificationService;
use App\Services\RegionTagResolver;
use App\Services\SupplierProjects\SupplierProjectResolver;
use Database\Seeders\PricingTierSeeder;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Random\Engine\Mt19937;
use Random\Randomizer;
use Tests\Concerns\SharesSupplierPdo;
uses(DatabaseTransactions::class);
uses(SharesSupplierPdo::class);
function runRouteJobB(int $id): void
{
(new RouteSupplierLeadJob($id))->handle(
app(LeadRouter::class),
app(SupplierProjectResolver::class),
app(NotificationService::class),
app(LedgerService::class),
app(LeadDistributor::class),
app(RegionTagResolver::class),
);
}
it('supplier_lead_deliveries table exists with PK (supplier_lead_id, tenant_id) and RLS', function (): void {
$cols = collect(DB::select(
"SELECT column_name FROM information_schema.columns WHERE table_name = 'supplier_lead_deliveries'"
))->pluck('column_name')->all();
expect($cols)->toContain('supplier_lead_id')
->toContain('tenant_id')
->toContain('deal_id')
->toContain('created_at');
$pk = collect(DB::select(
"SELECT a.attname FROM pg_index i
JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = ANY(i.indkey)
WHERE i.indrelid = 'supplier_lead_deliveries'::regclass AND i.indisprimary"
))->pluck('attname')->sort()->values()->all();
expect($pk)->toBe(['supplier_lead_id', 'tenant_id']);
$rls = DB::selectOne(
"SELECT relrowsecurity FROM pg_class WHERE relname = 'supplier_lead_deliveries'"
);
expect($rls->relrowsecurity)->toBeTrue();
});
it('one delivery to a tenant with 2 eligible projects → exactly 1 deal + 1 charge (max-remaining-limit tie-break)', function (): void {
$this->seed(PricingTierSeeder::class);
$sp = SupplierProject::factory()->create([
'platform' => 'B1', 'signal_type' => 'site', 'unique_key' => 'twoproj.ru',
]);
$tenant = Tenant::factory()->create(['balance_rub' => '100000.00']);
// Two eligible projects for the SAME tenant, different remaining limit.
$pLow = Project::factory()->create([
'tenant_id' => $tenant->id, 'is_active' => true,
'supplier_b1_project_id' => $sp->id,
'signal_type' => 'site', 'signal_identifier' => 'twoproj.ru',
'daily_limit_target' => 10, 'effective_daily_limit_today' => 10,
'delivered_today' => 9, 'delivery_days_mask' => 127, 'region_mask' => 255,
]);
$pHigh = Project::factory()->create([
'tenant_id' => $tenant->id, 'is_active' => true,
'supplier_b1_project_id' => $sp->id,
'signal_type' => 'site', 'signal_identifier' => 'twoproj.ru',
'daily_limit_target' => 10, 'effective_daily_limit_today' => 10,
'delivered_today' => 0, 'delivery_days_mask' => 127, 'region_mask' => 255,
]);
linkProjectToSupplier($pLow, $sp);
linkProjectToSupplier($pHigh, $sp);
$vid = 600001;
$lead = SupplierLead::factory()->create([
'supplier_project_id' => null, 'platform' => 'B1', 'vid' => $vid,
'phone' => '79991234567',
'raw_payload' => ['vid' => $vid, 'project' => 'B1_twoproj.ru', 'phone' => '79991234567', 'time' => now()->getTimestamp()],
]);
runRouteJobB($lead->id);
DB::statement("SET LOCAL app.current_tenant_id = '{$tenant->id}'");
expect(Deal::query()->where('source_crm_id', $vid)->count())->toBe(1);
expect(LeadCharge::query()->where('tenant_id', $tenant->id)->count())->toBe(1);
// The project with most remaining limit was chosen.
expect($pHigh->fresh()->delivered_today)->toBe(1);
expect($pLow->fresh()->delivered_today)->toBe(9);
});
it('lock: re-running same delivery to same tenant does not double-charge (Spec B)', function (): void {
$this->seed(PricingTierSeeder::class);
$sp = SupplierProject::factory()->create([
'platform' => 'B1', 'signal_type' => 'site', 'unique_key' => 'lock.ru',
]);
$tenant = Tenant::factory()->create(['balance_rub' => '100000.00']);
$p = Project::factory()->create([
'tenant_id' => $tenant->id, 'is_active' => true,
'supplier_b1_project_id' => $sp->id,
'signal_type' => 'site', 'signal_identifier' => 'lock.ru',
'daily_limit_target' => 10, 'effective_daily_limit_today' => 10,
'delivered_today' => 0, 'delivery_days_mask' => 127, 'region_mask' => 255,
]);
linkProjectToSupplier($p, $sp);
$vid = 610001;
$lead = SupplierLead::factory()->create([
'supplier_project_id' => null, 'platform' => 'B1', 'vid' => $vid,
'phone' => '79991234567',
'raw_payload' => ['vid' => $vid, 'project' => 'B1_lock.ru', 'phone' => '79991234567', 'time' => now()->getTimestamp()],
]);
runRouteJobB($lead->id);
// Reset processed_at to force a SECOND pass (bypass the existing $lead->processed_at idempotency
// guard so we are testing the DB-level lock specifically).
$lead->update(['processed_at' => null]);
runRouteJobB($lead->id);
DB::statement("SET LOCAL app.current_tenant_id = '{$tenant->id}'");
expect(Deal::query()->where('source_crm_id', $vid)->count())->toBe(1);
expect(LeadCharge::query()->where('tenant_id', $tenant->id)->count())->toBe(1);
expect(DB::table('supplier_lead_deliveries')
->where('supplier_lead_id', $lead->id)->where('tenant_id', $tenant->id)->count())->toBe(1);
// Balance debited exactly once.
expect((string) $tenant->fresh()->balance_rub)->toBe('99500.00');
});
it('same phone, two different deliveries to one tenant → both charged (no phone dedup, Spec B)', function (): void {
$this->seed(PricingTierSeeder::class);
$sp = SupplierProject::factory()->create([
'platform' => 'B1', 'signal_type' => 'site', 'unique_key' => 'twohit.ru',
]);
$tenant = Tenant::factory()->create(['balance_rub' => '100000.00']);
$p = Project::factory()->create([
'tenant_id' => $tenant->id, 'is_active' => true,
'supplier_b1_project_id' => $sp->id,
'signal_type' => 'site', 'signal_identifier' => 'twohit.ru',
'daily_limit_target' => 10, 'effective_daily_limit_today' => 10,
'delivered_today' => 0, 'delivery_days_mask' => 127, 'region_mask' => 255,
]);
linkProjectToSupplier($p, $sp);
foreach ([700001, 700002] as $vid) {
$lead = SupplierLead::factory()->create([
'supplier_project_id' => null, 'platform' => 'B1', 'vid' => $vid,
'phone' => '79991234567',
'raw_payload' => ['vid' => $vid, 'project' => 'B1_twohit.ru', 'phone' => '79991234567', 'time' => now()->getTimestamp()],
]);
runRouteJobB($lead->id);
}
DB::statement("SET LOCAL app.current_tenant_id = '{$tenant->id}'");
expect(Deal::query()->where('tenant_id', $tenant->id)->whereIn('source_crm_id', [700001, 700002])->count())->toBe(2);
expect(LeadCharge::query()->where('tenant_id', $tenant->id)->count())->toBe(2);
expect((string) $tenant->fresh()->balance_rub)->toBe('99000.00');
});
it('cap = 3 distinct tenants: 5 eligible tenants → exactly 3 charged (Spec B)', function (): void {
$this->seed(PricingTierSeeder::class);
app()->bind(LeadDistributor::class, fn () => new LeadDistributor(new Randomizer(new Mt19937(7))));
$sp = SupplierProject::factory()->create([
'platform' => 'B1', 'signal_type' => 'site', 'unique_key' => 'cap3.ru',
]);
foreach (range(1, 5) as $i) {
$t = Tenant::factory()->create(['balance_rub' => '100000.00']);
$p = Project::factory()->create([
'tenant_id' => $t->id, 'is_active' => true,
'supplier_b1_project_id' => $sp->id,
'signal_type' => 'site', 'signal_identifier' => 'cap3.ru',
'daily_limit_target' => 10, 'effective_daily_limit_today' => 10,
'delivered_today' => 0, 'delivery_days_mask' => 127, 'region_mask' => 255,
]);
linkProjectToSupplier($p, $sp);
}
$vid = 710001;
$lead = SupplierLead::factory()->create([
'supplier_project_id' => null, 'platform' => 'B1', 'vid' => $vid,
'phone' => '79991234567',
'raw_payload' => ['vid' => $vid, 'project' => 'B1_cap3.ru', 'phone' => '79991234567', 'time' => now()->getTimestamp()],
]);
runRouteJobB($lead->id);
$lead->refresh();
expect($lead->deals_created_count)->toBe(3);
expect(DB::table('supplier_lead_deliveries')->where('supplier_lead_id', $lead->id)->count())->toBe(3);
expect(DB::table('supplier_lead_deliveries')->where('supplier_lead_id', $lead->id)->distinct()->count('tenant_id'))->toBe(3);
});