Files
portal/app/tests/Feature/Billing/TenantChargesControllerTest.php
T

168 lines
6.0 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)/');
});