Files
portal/app/tests/Feature/AdminTenantShowTest.php
T
Дмитрий f9d8926945 phase2(admin-tenant-detail-backend): GET /api/admin/tenants/{subdomain} с 4 секциями
- AdminTenantsController +show($subdomain): возвращает tenant base + users +
  projects + balance_history + activity + computed metrics (leads_today/week/
  month, avg_lead_cost_rub, runway_days). Lookup по subdomain (естественный
  URL slug) + whereNull deleted_at. Без auth-middleware (saas-admin SSO ⏸ Б-1).
- 4 private fetch'ера + computeMetrics:
  - fetchUsers: ORDER last_active_at DESC, LIMIT 50, поля email/first/last/
    is_active/totp_enabled/last_active_at/last_login_at.
  - fetchProjects: LEFT JOIN sub-queries для suppliers_count + leads_today
    (deals в текущем дне). Поля name/tag/is_active/daily_limit_target.
  - fetchBalanceHistory: ORDER created_at DESC, LIMIT 30. Поля type/amount_rub/
    amount_leads/balance_rub_after/description/created_at.
  - fetchActivity: LEFT JOIN users (actor_email), LIMIT 20. context json_decode.
  - computeMetrics: один SELECT с FILTER для leads counts; AVG cost_rub за
    30 дней; runway_days = balance / (month_spend / 30).
- routes/web.php: GET /api/admin/tenants/{subdomain} where [a-z0-9_-]+.
- Pest +13 в AdminTenantShowTest.php (всего 416/416, +13 от 403, 1388 assertions):
  404 unknown / 404 soft-deleted / базовые поля / 4 секции + metrics keys в response /
  users изоляция / projects suppliers_count + leads_today / balance_history ORDER+LIMIT 30 /
  balance_history изоляция / activity actor_email LEFT JOIN (user + system events) /
  metrics leads_today/week/month / metrics runway_days computed / tariff_name+mrr_rub /
  mrr_rub null для trial.
- phpstan-baseline регенерирован.

Этап A эпика AdminTenantDetailView (backend) закрыт. Этап B: frontend
integration + Vitest.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 14:32:24 +03:00

264 lines
11 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
use App\Models\Project;
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
uses(DatabaseTransactions::class);
beforeEach(function () {
$this->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' => 'worked']),
'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();
});