tenant = Tenant::factory()->create([ 'subdomain' => 'okna-moscow', 'organization_name' => 'Окна Москва ООО', 'contact_email' => 'admin@okna-moscow.ru', 'is_trial' => false, 'balance_rub' => 14250.00, 'balance_leads' => 5, ]); }); test('GET /api/admin/tenants/{subdomain}: 404 unknown', function () { $this->getJson('/api/admin/tenants/unknown-subdomain')->assertStatus(404); }); test('GET /api/admin/tenants/{subdomain}: 404 для soft-deleted', function () { DB::table('tenants')->where('id', $this->tenant->id)->update(['deleted_at' => Carbon::now()]); $this->getJson("/api/admin/tenants/{$this->tenant->subdomain}")->assertStatus(404); }); test('GET /api/admin/tenants/{subdomain}: возвращает базовые поля', function () { $response = $this->getJson("/api/admin/tenants/{$this->tenant->subdomain}"); $response->assertOk(); expect($response->json('tenant.subdomain'))->toBe('okna-moscow'); expect($response->json('tenant.organization_name'))->toBe('Окна Москва ООО'); expect($response->json('tenant.contact_email'))->toBe('admin@okna-moscow.ru'); expect($response->json('tenant.balance_rub'))->toBe('14250.00'); expect($response->json('tenant.balance_leads'))->toBe(5); expect($response->json('tenant.is_trial'))->toBeFalse(); }); test('GET /api/admin/tenants/{subdomain}: response содержит 4 секции + metrics', function () { $response = $this->getJson("/api/admin/tenants/{$this->tenant->subdomain}"); $response->assertOk(); expect($response->json())->toHaveKey('users'); expect($response->json())->toHaveKey('projects'); expect($response->json())->toHaveKey('balance_history'); expect($response->json())->toHaveKey('activity'); expect($response->json())->toHaveKey('metrics'); expect($response->json('metrics'))->toHaveKey('leads_today'); expect($response->json('metrics'))->toHaveKey('avg_lead_cost_rub'); expect($response->json('metrics'))->toHaveKey('runway_days'); }); test('GET show: users возвращает свои + изоляция от чужих', function () { User::factory()->create(['tenant_id' => $this->tenant->id, 'email' => 'mine@test.io']); $other = Tenant::factory()->create(['subdomain' => 'other-co']); User::factory()->create(['tenant_id' => $other->id, 'email' => 'foreign@test.io']); $response = $this->getJson("/api/admin/tenants/{$this->tenant->subdomain}"); $emails = array_column($response->json('users'), 'email'); expect($emails)->toContain('mine@test.io'); expect($emails)->not->toContain('foreign@test.io'); }); test('GET show: projects возвращает с suppliers_count + leads_today', function () { $project = Project::factory()->create([ 'tenant_id' => $this->tenant->id, 'name' => 'Натяжные потолки', 'tag' => 'natyazhnye-potolki', 'is_active' => true, ]); // suppliers — глобальная таблица (без tenant_id), code UNIQUE. $supplierIds = []; foreach (range(1, 2) as $i) { $supplierIds[] = DB::table('suppliers')->insertGetId([ 'code' => 'b'.Str::random(6), 'name' => "Supplier {$i}", 'accepts_types' => '{websites}', 'cost_rub' => 100.00, 'channel' => 'sites', 'created_at' => Carbon::now(), 'updated_at' => Carbon::now(), ]); } foreach ($supplierIds as $sid) { DB::table('project_suppliers')->insert([ 'project_id' => $project->id, 'supplier_id' => $sid, 'is_active' => true, 'created_at' => Carbon::now(), 'updated_at' => Carbon::now(), ]); } // 2 deals сегодня для проекта (используем сегодняшний день — партиция точно есть). foreach (range(1, 2) as $i) { DB::table('deals')->insert([ 'tenant_id' => $this->tenant->id, 'project_id' => $project->id, 'phone' => "+7999000000{$i}", 'status' => 'new', 'received_at' => Carbon::now(), 'created_at' => Carbon::now(), 'updated_at' => Carbon::now(), ]); } $response = $this->getJson("/api/admin/tenants/{$this->tenant->subdomain}"); $projects = $response->json('projects'); expect($projects)->toHaveCount(1); expect($projects[0]['name'])->toBe('Натяжные потолки'); expect($projects[0]['suppliers_count'])->toBe(2); expect($projects[0]['leads_today'])->toBe(2); }); test('GET show: balance_history возвращает свои tx + ORDER created_at DESC + LIMIT 30', function () { foreach (range(1, 35) as $i) { DB::table('balance_transactions')->insert([ 'tenant_id' => $this->tenant->id, 'type' => $i % 3 === 0 ? 'topup' : 'lead_charge', 'amount_rub' => $i % 3 === 0 ? 1000 : -200, 'created_at' => Carbon::now()->subMinutes(35 - $i), ]); } $response = $this->getJson("/api/admin/tenants/{$this->tenant->subdomain}"); $history = $response->json('balance_history'); expect($history)->toHaveCount(30); // LIMIT // Самая свежая первая (created_at DESC). expect($history[0]['type'])->toBeIn(['topup', 'lead_charge']); }); test('GET show: balance_history изоляция (чужие tx не показываются)', function () { DB::table('balance_transactions')->insert([ 'tenant_id' => $this->tenant->id, 'type' => 'topup', 'amount_rub' => 999, 'description' => 'mine', 'created_at' => Carbon::now(), ]); $other = Tenant::factory()->create(); DB::table('balance_transactions')->insert([ 'tenant_id' => $other->id, 'type' => 'topup', 'amount_rub' => 555, 'description' => 'foreign', 'created_at' => Carbon::now(), ]); $response = $this->getJson("/api/admin/tenants/{$this->tenant->subdomain}"); $descriptions = array_column($response->json('balance_history'), 'description'); expect($descriptions)->toContain('mine'); expect($descriptions)->not->toContain('foreign'); }); test('GET show: activity возвращает с actor_email из users LEFT JOIN', function () { $user = User::factory()->create(['tenant_id' => $this->tenant->id, 'email' => 'actor@test.io']); DB::table('activity_log')->insert([ 'tenant_id' => $this->tenant->id, 'user_id' => $user->id, 'deal_id' => 999, 'event' => 'deal.status_changed', 'context' => json_encode(['from' => 'new', 'to' => 'in_progress']), 'created_at' => Carbon::now(), ]); DB::table('activity_log')->insert([ 'tenant_id' => $this->tenant->id, 'user_id' => null, // системное событие 'deal_id' => 1000, 'event' => 'webhook.received', 'created_at' => Carbon::now()->subMinute(), ]); $response = $this->getJson("/api/admin/tenants/{$this->tenant->subdomain}"); $activity = $response->json('activity'); expect($activity)->toHaveCount(2); $events = array_column($activity, 'event'); expect($events)->toContain('deal.status_changed'); expect($events)->toContain('webhook.received'); // Самая свежая первая. expect($activity[0]['event'])->toBe('deal.status_changed'); expect($activity[0]['actor_email'])->toBe('actor@test.io'); expect($activity[1]['actor_email'])->toBeNull(); }); test('GET show: metrics.leads_today + this_week + this_month', function () { $project = Project::factory()->create(['tenant_id' => $this->tenant->id]); // 1 deal сегодня DB::table('deals')->insert([ 'tenant_id' => $this->tenant->id, 'project_id' => $project->id, 'phone' => '+79991111111', 'status' => 'new', 'received_at' => Carbon::now(), 'created_at' => Carbon::now(), 'updated_at' => Carbon::now(), ]); $response = $this->getJson("/api/admin/tenants/{$this->tenant->subdomain}"); expect($response->json('metrics.leads_today'))->toBe(1); expect($response->json('metrics.leads_this_week'))->toBeGreaterThanOrEqual(1); expect($response->json('metrics.leads_this_month'))->toBeGreaterThanOrEqual(1); }); test('GET show: metrics.runway_days computed из baalance + spend', function () { // 30000 ₽ списано за 30 дней → avg_daily = 1000. // Баланс 14250 → runway ~ 14 дней. DB::table('balance_transactions')->insert([ 'tenant_id' => $this->tenant->id, 'type' => 'lead_charge', 'amount_rub' => -30000, 'created_at' => Carbon::now()->subDays(15), ]); $response = $this->getJson("/api/admin/tenants/{$this->tenant->subdomain}"); $runway = $response->json('metrics.runway_days'); expect($runway)->toBeGreaterThan(10); expect($runway)->toBeLessThan(20); }); test('GET show: tariff_name + mrr_rub когда есть current_tariff_id', function () { $tariffId = DB::table('tariff_plans')->insertGetId([ 'code' => 'team-'.Str::random(6), 'name' => 'Команда', 'billing_model' => 'monthly', 'price_monthly' => 990.00, 'is_active' => true, 'created_at' => Carbon::now(), 'updated_at' => Carbon::now(), ]); DB::table('tenants')->where('id', $this->tenant->id)->update(['current_tariff_id' => $tariffId]); $response = $this->getJson("/api/admin/tenants/{$this->tenant->subdomain}"); expect($response->json('tenant.tariff_name'))->toBe('Команда'); expect($response->json('tenant.mrr_rub'))->toBe('990.00'); }); test('GET show: mrr_rub null для trial-тенанта (даже если есть тариф)', function () { $tariffId = DB::table('tariff_plans')->insertGetId([ 'code' => 'team-tr-'.Str::random(6), 'name' => 'Команда', 'billing_model' => 'monthly', 'price_monthly' => 990.00, 'is_active' => true, 'created_at' => Carbon::now(), 'updated_at' => Carbon::now(), ]); DB::table('tenants')->where('id', $this->tenant->id)->update([ 'is_trial' => true, 'current_tariff_id' => $tariffId, ]); $response = $this->getJson("/api/admin/tenants/{$this->tenant->subdomain}"); expect($response->json('tenant.is_trial'))->toBeTrue(); expect($response->json('tenant.mrr_rub'))->toBeNull(); });