Files
portal/app/tests/Feature/Billing/TenantChargesControllerTest.php
T
Дмитрий f888c4baec
Accessibility (Pa11y live) / a11y (push) Has been cancelled
SAST — Semgrep / Semgrep SAST scan (push) Has been cancelled
chore(autopodbor): кнопка «Отказаться» (было «Удалить») + автоподбор в CSV списаний + canon-sync schema.sql v8.63
- Кнопка на актуализации переименована «Удалить»→«Отказаться» (чтобы клиент не путал с удалением конкурента).
- CSV-выгрузка «Списаний» теперь включает автоподбор-строки (отдельным блоком, «Автоподбор конкурентов»);
  тест на export. charges 11/11.
- schema.sql canon-sync v8.63: +колонка dismissed_actualize_keys у autopodbor_competitors + CHANGELOG
  (заодно в цепочку шапки вписана пропущенная v8.62 CHECK autopodbor_charge).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 16:03:43 +03:00

224 lines
8.2 KiB
PHP

<?php
declare(strict_types=1);
use App\Models\BalanceTransaction;
use App\Models\Deal;
use App\Models\LeadCharge;
use App\Models\Tenant;
use App\Models\User;
use Database\Seeders\PricingTierSeeder;
use Illuminate\Foundation\Testing\DatabaseTransactions;
/**
* Tenant-scoped read-only charges ledger (Plan 4 Task 11).
*
* GET /api/billing/charges — paginated 20/page (sorted by charged_at desc).
* POST /api/billing/charges/export — StreamedResponse CSV (BOM + chunkById 500).
* Filters: period (current_month / last_month / 90d) + charge_source (prepaid / rub).
*
* RLS изоляция через SetTenantContext middleware (auth:sanctum + tenant). Тесты
* используют postgres superuser BYPASSRLS, поэтому RLS-isolation проверяется
* через `auth()->user()->tenant_id` фильтр — другие tenant'ы видны только если
* мы сами их INSERT'нули, но фильтрация теста на $this->tenant->id всё равно
* остаётся защитой.
*
* Spec: docs/superpowers/specs/2026-05-11-plan4-billing-csv-admin-design.md §6.3
*/
uses(DatabaseTransactions::class);
beforeEach(function () {
// PricingTierSeeder идемпотентен (updateOrCreate); seed безопасно.
$this->seed(PricingTierSeeder::class);
$this->tenant = Tenant::factory()->create();
$this->user = User::factory()->create(['tenant_id' => $this->tenant->id]);
$this->actingAs($this->user);
});
function makeChargeFor(Tenant $tenant, array $overrides = []): LeadCharge
{
$deal = Deal::factory()->create([
'tenant_id' => $tenant->id,
'received_at' => now(),
]);
return LeadCharge::factory()->create(array_merge([
'tenant_id' => $tenant->id,
'deal_id' => $deal->id,
'deal_received_at' => $deal->received_at,
'charged_at' => now(),
], $overrides));
}
it('GET /api/billing/charges returns paginated list for current tenant only (RLS)', function () {
makeChargeFor($this->tenant);
makeChargeFor($this->tenant);
$otherTenant = Tenant::factory()->create();
makeChargeFor($otherTenant);
$response = $this->getJson('/api/billing/charges');
$response->assertOk();
expect($response->json('data'))->toHaveCount(2);
});
it('filters by charge_source=prepaid', function () {
makeChargeFor($this->tenant, ['charge_source' => 'rub', 'price_per_lead_kopecks' => 50000]);
makeChargeFor($this->tenant, ['charge_source' => 'prepaid', 'price_per_lead_kopecks' => 0]);
makeChargeFor($this->tenant, ['charge_source' => 'prepaid', 'price_per_lead_kopecks' => 0]);
$response = $this->getJson('/api/billing/charges?charge_source=prepaid');
expect($response->json('data'))->toHaveCount(2);
});
it('filters by period=current_month / last_month / 90d', function () {
makeChargeFor($this->tenant, ['charged_at' => now()]);
makeChargeFor($this->tenant, ['charged_at' => now()->subMonth()]);
makeChargeFor($this->tenant, ['charged_at' => now()->subDays(60)]);
makeChargeFor($this->tenant, ['charged_at' => now()->subDays(120)]);
$this->getJson('/api/billing/charges?period=current_month')->assertJsonCount(1, 'data');
$this->getJson('/api/billing/charges?period=last_month')->assertJsonCount(1, 'data');
$this->getJson('/api/billing/charges?period=90d')->assertJsonCount(3, 'data');
});
it('returns 401 без auth', function () {
auth()->logout();
$this->getJson('/api/billing/charges')->assertStatus(401);
});
it('pagination: ?page=2 returns next slice', function () {
for ($i = 0; $i < 30; $i++) {
makeChargeFor($this->tenant);
}
$page1 = $this->getJson('/api/billing/charges?page=1');
$page2 = $this->getJson('/api/billing/charges?page=2');
expect($page1->json('data'))->toHaveCount(20);
expect($page2->json('data'))->toHaveCount(10);
});
it('POST /export streams CSV via StreamedResponse', function () {
makeChargeFor($this->tenant);
$response = $this->postJson('/api/billing/charges/export', ['period' => '90d']);
$response->assertOk();
$response->assertHeader('Content-Type', 'text/csv; charset=UTF-8');
$body = $response->streamedContent();
expect($body)->toContain('charged_at,deal_id,tier_no,charge_source,price_rub,balance_rub_after');
});
it('export заполняет balance_rub_after из balance_transactions JOIN', function () {
$deal = Deal::factory()->create([
'tenant_id' => $this->tenant->id,
'received_at' => now(),
]);
LeadCharge::factory()->create([
'tenant_id' => $this->tenant->id,
'deal_id' => $deal->id,
'deal_received_at' => $deal->received_at,
'tier_no' => 1,
'price_per_lead_kopecks' => 50000,
'charge_source' => 'rub',
'charged_at' => now(),
]);
BalanceTransaction::create([
'tenant_id' => $this->tenant->id,
'type' => BalanceTransaction::TYPE_LEAD_CHARGE,
'amount_rub' => '-500.00',
'amount_leads' => null,
'balance_rub_after' => '4500.00',
'related_type' => Deal::class,
'related_id' => $deal->id,
'created_at' => now(),
]);
$response = $this->postJson('/api/billing/charges/export');
$response->assertOk();
$csv = $response->streamedContent();
expect($csv)->toContain('4500.00');
});
test('TenantChargesController::export emits charged_at in ISO-8601 format (regression A.10 fix)', function () {
$deal = Deal::factory()->create([
'tenant_id' => $this->tenant->id,
'received_at' => now(),
]);
LeadCharge::create([
'tenant_id' => $this->tenant->id,
'deal_id' => $deal->id,
'deal_received_at' => $deal->received_at,
'tier_no' => 1,
'price_per_lead_kopecks' => 50000,
'charge_source' => 'rub',
'charged_at' => now(),
]);
$body = $this->post('/api/billing/charges/export')->streamedContent();
// ISO-8601 marker: "T" between date and time, and trailing "+" or "Z" timezone.
expect($body)->toMatch('/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}([+-]\d{2}:\d{2}|Z)/');
});
it('включает списания автоподбора (autopodbor_charge) в список списаний', function () {
makeChargeFor($this->tenant); // 1 лид-списание
BalanceTransaction::factory()->create([
'tenant_id' => $this->tenant->id,
'type' => 'autopodbor_charge',
'amount_rub' => '-50.00',
'balance_rub_after' => '100.00',
'created_at' => now(),
]);
$response = $this->getJson('/api/billing/charges');
$response->assertOk();
$data = collect($response->json('data'));
expect($data)->toHaveCount(2);
$auto = $data->firstWhere('kind', 'autopodbor');
expect($auto)->not->toBeNull()
->and($auto['label'])->toBe('Автоподбор конкурентов')
->and((int) $auto['price_per_lead_kopecks'])->toBe(5000)
->and($auto['deal_id'])->toBeNull();
});
it('не подмешивает автоподбор при фильтре charge_source (у него нет источника)', function () {
makeChargeFor($this->tenant, ['charge_source' => 'rub', 'price_per_lead_kopecks' => 50000]);
BalanceTransaction::factory()->create([
'tenant_id' => $this->tenant->id,
'type' => 'autopodbor_charge',
'amount_rub' => '-50.00',
'balance_rub_after' => '100.00',
'created_at' => now(),
]);
$response = $this->getJson('/api/billing/charges?charge_source=rub');
$response->assertOk();
$data = collect($response->json('data'));
expect($data)->toHaveCount(1)
->and($data->firstWhere('kind', 'autopodbor'))->toBeNull();
});
it('export включает списания автоподбора отдельными строками', function () {
makeChargeFor($this->tenant);
BalanceTransaction::factory()->create([
'tenant_id' => $this->tenant->id,
'type' => 'autopodbor_charge',
'amount_rub' => '-50.00',
'balance_rub_after' => '100.00',
'created_at' => now(),
]);
$body = $this->postJson('/api/billing/charges/export')->assertOk()->streamedContent();
expect($body)->toContain('Автоподбор конкурентов')
->and($body)->toContain('50.00');
});