6ef9961f5f
AdminTenantsView переходит с mock-данных на live backend.
Backend (AdminTenantsController::index):
- GET /api/admin/tenants?status=&search=&limit=&offset=.
- LEFT JOIN tariff_plans для tariff_name. ORDER BY last_activity_at DESC.
- ILIKE search по organization_name + subdomain + contact_email.
- stats {total, active, trial, overdue} — overdue считает balance<0
ИЛИ chargeback_unrecovered_rub > 0.
- На MVP без auth (saas-admin SSO ⏸ Б-1).
Pest +8 (AdminTenantsIndexTest):
- 200 + пустой / все поля / status filter / search ILIKE /
ORDER BY last_activity_at DESC / stats / soft-deleted скрыт /
limit+offset.
Frontend:
- api/admin.ts::listAdminTenants — типизированный helper.
- composables/adminTenantsMapper.ts::mapApiAdminTenant — converter
API → UI: status derive (is_trial→trial, chargeback>0||balance<0
→overdue), inn='', code=subdomain, tariff clamp на known TenantTariff,
todayActual/mrrRub отсутствуют в API → 0/null, activitySince через
formatRelative.
- AdminTenantsView: reactive tenantsState+stats default = MOCK,
loadTenants() на onMounted → splice replace; на fail — fetchError +
warning alert + MOCK fallback. Reload-btn.
Vitest +13:
- View-integration (4): listAdminTenants на mount / replace state+stats /
reject → fetchError + alert + fallback / reload-btn x2.
- Mapper (9): name/code/inn/status-derives (trial/overdue/suspended) /
balance_rub→number / activitySince + null fallback.
PHPStan baseline регенерирован.
Регресс:
- Lint+type-check+format passed.
- Vitest 296/296 за 18.91 сек (+13 от 283).
- Vite build 1.02 сек.
- Pint + PHPStan passed.
- Pest 228/228 за 25.22 сек (+8 от 220, 906 assertions).
Реестр v1.65→v1.66 / CLAUDE.md v1.56→v1.57.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
136 lines
5.4 KiB
PHP
136 lines
5.4 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
use App\Models\Tenant;
|
|
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
|
use Illuminate\Support\Facades\DB;
|
|
|
|
uses(DatabaseTransactions::class);
|
|
|
|
beforeEach(function () {
|
|
// Чистим всё что было в seed/предыдущих тестах — для чистых счётчиков stats.
|
|
DB::table('tenants')->delete();
|
|
});
|
|
|
|
test('GET /api/admin/tenants возвращает 200 и пустой список без данных', function () {
|
|
$r = $this->getJson('/api/admin/tenants');
|
|
|
|
$r->assertStatus(200);
|
|
expect($r->json('tenants'))->toBe([]);
|
|
expect($r->json('total'))->toBe(0);
|
|
expect($r->json('limit'))->toBe(100);
|
|
expect($r->json('offset'))->toBe(0);
|
|
});
|
|
|
|
test('GET /api/admin/tenants возвращает все поля', function () {
|
|
$tenant = Tenant::factory()->create([
|
|
'subdomain' => 'okna-msk',
|
|
'organization_name' => 'Окна Москва',
|
|
'contact_email' => 'admin@okna-msk.test',
|
|
'balance_rub' => '15000.00',
|
|
'balance_leads' => 287,
|
|
'is_trial' => false,
|
|
'desired_daily_numbers' => 12,
|
|
]);
|
|
|
|
$r = $this->getJson('/api/admin/tenants');
|
|
|
|
$r->assertStatus(200);
|
|
expect($r->json('total'))->toBe(1);
|
|
$row = $r->json('tenants.0');
|
|
expect($row['id'])->toBe($tenant->id);
|
|
expect($row['subdomain'])->toBe('okna-msk');
|
|
expect($row['organization_name'])->toBe('Окна Москва');
|
|
expect($row['contact_email'])->toBe('admin@okna-msk.test');
|
|
expect($row['balance_rub'])->toBe('15000.00');
|
|
expect($row['balance_leads'])->toBe(287);
|
|
expect($row['is_trial'])->toBeFalse();
|
|
expect($row['desired_daily_numbers'])->toBe(12);
|
|
});
|
|
|
|
test('GET /api/admin/tenants фильтрует по status', function () {
|
|
Tenant::factory()->create(['status' => 'active', 'organization_name' => 'A']);
|
|
Tenant::factory()->create(['status' => 'suspended', 'organization_name' => 'B']);
|
|
Tenant::factory()->create(['status' => 'active', 'organization_name' => 'C']);
|
|
|
|
$r = $this->getJson('/api/admin/tenants?status=active');
|
|
|
|
expect($r->json('total'))->toBe(2);
|
|
$names = collect($r->json('tenants'))->pluck('organization_name')->all();
|
|
expect($names)->toContain('A');
|
|
expect($names)->toContain('C');
|
|
expect($names)->not->toContain('B');
|
|
});
|
|
|
|
test('GET /api/admin/tenants фильтрует по search (organization_name + subdomain + email ILIKE)', function () {
|
|
Tenant::factory()->create(['organization_name' => 'Окна Москва', 'subdomain' => 'okna']);
|
|
Tenant::factory()->create(['organization_name' => 'Двери СПб', 'subdomain' => 'dveri']);
|
|
|
|
expect($this->getJson('/api/admin/tenants?search=окна')->json('total'))->toBe(1);
|
|
expect($this->getJson('/api/admin/tenants?search=DVERI')->json('total'))->toBe(1);
|
|
expect($this->getJson('/api/admin/tenants?search=что-то-несуществующее')->json('total'))->toBe(0);
|
|
});
|
|
|
|
test('GET /api/admin/tenants сортирует по last_activity_at DESC', function () {
|
|
$oldest = Tenant::factory()->create([
|
|
'organization_name' => 'oldest',
|
|
'last_activity_at' => now()->subDays(5),
|
|
]);
|
|
$newest = Tenant::factory()->create([
|
|
'organization_name' => 'newest',
|
|
'last_activity_at' => now()->subMinutes(5),
|
|
]);
|
|
$middle = Tenant::factory()->create([
|
|
'organization_name' => 'middle',
|
|
'last_activity_at' => now()->subDays(1),
|
|
]);
|
|
|
|
$r = $this->getJson('/api/admin/tenants');
|
|
$names = collect($r->json('tenants'))->pluck('organization_name')->all();
|
|
expect($names[0])->toBe('newest');
|
|
expect($names[1])->toBe('middle');
|
|
expect($names[2])->toBe('oldest');
|
|
});
|
|
|
|
test('GET /api/admin/tenants возвращает stats (total/active/trial/overdue)', function () {
|
|
Tenant::factory()->create(['status' => 'active', 'is_trial' => false, 'balance_rub' => '100']);
|
|
Tenant::factory()->create(['status' => 'active', 'is_trial' => true, 'balance_rub' => '50']);
|
|
Tenant::factory()->create(['status' => 'suspended', 'is_trial' => false, 'balance_rub' => '-200']);
|
|
Tenant::factory()->create(['status' => 'active', 'is_trial' => false, 'chargeback_unrecovered_rub' => '500']);
|
|
|
|
$r = $this->getJson('/api/admin/tenants');
|
|
|
|
expect($r->json('stats.total'))->toBe(4);
|
|
expect($r->json('stats.active'))->toBe(3);
|
|
expect($r->json('stats.trial'))->toBe(1);
|
|
expect($r->json('stats.overdue'))->toBe(2); // отрицательный balance + chargeback
|
|
});
|
|
|
|
test('GET /api/admin/tenants soft-deleted tenant НЕ возвращается', function () {
|
|
$deleted = Tenant::factory()->create(['organization_name' => 'удалён']);
|
|
$deleted->delete(); // soft-delete
|
|
|
|
Tenant::factory()->create(['organization_name' => 'жив']);
|
|
|
|
$r = $this->getJson('/api/admin/tenants');
|
|
|
|
expect($r->json('total'))->toBe(1);
|
|
expect($r->json('tenants.0.organization_name'))->toBe('жив');
|
|
});
|
|
|
|
test('GET /api/admin/tenants поддерживает limit + offset', function () {
|
|
foreach (range(1, 5) as $i) {
|
|
Tenant::factory()->create([
|
|
'organization_name' => 'T'.$i,
|
|
'last_activity_at' => now()->subMinutes($i),
|
|
]);
|
|
}
|
|
|
|
$r = $this->getJson('/api/admin/tenants?limit=2&offset=1');
|
|
expect($r->json('total'))->toBe(5);
|
|
expect($r->json('limit'))->toBe(2);
|
|
expect($r->json('offset'))->toBe(1);
|
|
expect(count($r->json('tenants')))->toBe(2);
|
|
});
|