440 lines
17 KiB
PHP
440 lines
17 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
use App\Jobs\GenerateReportJob;
|
||
use App\Models\BalanceTransaction;
|
||
use App\Models\Project;
|
||
use App\Models\ReportJob;
|
||
use App\Models\Tenant;
|
||
use App\Models\User;
|
||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||
use Illuminate\Support\Carbon;
|
||
use Illuminate\Support\Facades\Bus;
|
||
use Illuminate\Support\Facades\DB;
|
||
use Illuminate\Support\Facades\Storage;
|
||
|
||
uses(DatabaseTransactions::class);
|
||
|
||
beforeEach(function () {
|
||
$this->tenant = Tenant::factory()->create();
|
||
$this->user = User::factory()->create(['tenant_id' => $this->tenant->id]);
|
||
$this->actingAs($this->user);
|
||
Storage::fake('local');
|
||
});
|
||
|
||
function makeReportJob(int $tenantId, int $userId, array $overrides = []): ReportJob
|
||
{
|
||
return ReportJob::create(array_merge([
|
||
'tenant_id' => $tenantId,
|
||
'user_id' => $userId,
|
||
'type' => 'deals_export',
|
||
'parameters' => ['format' => 'csv', 'date_from' => '2026-04-01', 'date_to' => '2026-04-30'],
|
||
'status' => ReportJob::STATUS_PENDING,
|
||
], $overrides));
|
||
}
|
||
|
||
test('GET /api/reports/jobs: 401 без auth', function () {
|
||
auth()->logout();
|
||
$this->getJson('/api/reports/jobs')->assertStatus(401);
|
||
});
|
||
|
||
test('GET /api/reports/jobs: пустой', function () {
|
||
$response = $this->getJson('/api/reports/jobs');
|
||
$response->assertOk();
|
||
expect($response->json('jobs'))->toBe([]);
|
||
expect($response->json('total'))->toBe(0);
|
||
expect($response->json('quota.max_active'))->toBe(3);
|
||
expect($response->json('quota.active'))->toBe(0);
|
||
});
|
||
|
||
test('GET /api/reports/jobs: возвращает только свои', function () {
|
||
$other = Tenant::factory()->create();
|
||
$otherUser = User::factory()->create(['tenant_id' => $other->id]);
|
||
makeReportJob($this->tenant->id, $this->user->id);
|
||
makeReportJob($other->id, $otherUser->id);
|
||
|
||
$response = $this->getJson('/api/reports/jobs');
|
||
expect($response->json('jobs'))->toHaveCount(1);
|
||
expect($response->json('total'))->toBe(1);
|
||
});
|
||
|
||
test('GET /api/reports/jobs: ORDER BY created_at DESC', function () {
|
||
$j1 = makeReportJob($this->tenant->id, $this->user->id);
|
||
DB::table('report_jobs')->where('id', $j1->id)->update(['created_at' => Carbon::now()->subHours(2)]);
|
||
$j2 = makeReportJob($this->tenant->id, $this->user->id);
|
||
DB::table('report_jobs')->where('id', $j2->id)->update(['created_at' => Carbon::now()->subHour()]);
|
||
$j3 = makeReportJob($this->tenant->id, $this->user->id);
|
||
|
||
$response = $this->getJson('/api/reports/jobs');
|
||
$ids = array_column($response->json('jobs'), 'id');
|
||
expect($ids)->toBe([$j3->id, $j2->id, $j1->id]);
|
||
});
|
||
|
||
test('GET /api/reports/jobs?status=done: фильтр по статусу', function () {
|
||
makeReportJob($this->tenant->id, $this->user->id);
|
||
makeReportJob($this->tenant->id, $this->user->id, ['status' => ReportJob::STATUS_DONE, 'file_path' => 'reports/x/1.csv']);
|
||
|
||
$response = $this->getJson('/api/reports/jobs?status=done');
|
||
expect($response->json('jobs'))->toHaveCount(1);
|
||
expect($response->json('jobs.0.status'))->toBe('done');
|
||
});
|
||
|
||
test('GET /api/reports/jobs: counts по 4 статусам', function () {
|
||
makeReportJob($this->tenant->id, $this->user->id);
|
||
makeReportJob($this->tenant->id, $this->user->id, ['status' => ReportJob::STATUS_PROCESSING]);
|
||
makeReportJob($this->tenant->id, $this->user->id, ['status' => ReportJob::STATUS_DONE]);
|
||
makeReportJob($this->tenant->id, $this->user->id, ['status' => ReportJob::STATUS_FAILED]);
|
||
|
||
$response = $this->getJson('/api/reports/jobs');
|
||
expect($response->json('counts'))->toBe([
|
||
'pending' => 1, 'processing' => 1, 'done' => 1, 'failed' => 1,
|
||
]);
|
||
expect($response->json('quota.active'))->toBe(2); // pending + processing
|
||
});
|
||
|
||
test('GET /api/reports/jobs: limit + offset', function () {
|
||
foreach (range(1, 5) as $_) {
|
||
makeReportJob($this->tenant->id, $this->user->id);
|
||
}
|
||
$response = $this->getJson('/api/reports/jobs?limit=2&offset=1');
|
||
expect($response->json('jobs'))->toHaveCount(2);
|
||
expect($response->json('total'))->toBe(5);
|
||
});
|
||
|
||
test('GET /api/reports/jobs?status=invalid: 422', function () {
|
||
$this->getJson('/api/reports/jobs?status=invalid')->assertStatus(422);
|
||
});
|
||
|
||
test('GET /api/reports/jobs/{id}: success + поля', function () {
|
||
$job = makeReportJob($this->tenant->id, $this->user->id, ['status' => ReportJob::STATUS_DONE, 'file_path' => 'reports/1/2.csv', 'file_size' => 512]);
|
||
$response = $this->getJson("/api/reports/jobs/{$job->id}");
|
||
$response->assertOk();
|
||
expect($response->json('job.id'))->toBe($job->id);
|
||
expect($response->json('job.status'))->toBe('done');
|
||
expect($response->json('job.file_size'))->toBe(512);
|
||
});
|
||
|
||
test('GET /api/reports/jobs/{id}: 404 unknown', function () {
|
||
$this->getJson('/api/reports/jobs/999999')->assertStatus(404);
|
||
});
|
||
|
||
test('GET /api/reports/jobs/{id}: 404 чужой', function () {
|
||
$other = Tenant::factory()->create();
|
||
$otherUser = User::factory()->create(['tenant_id' => $other->id]);
|
||
$job = makeReportJob($other->id, $otherUser->id);
|
||
$this->getJson("/api/reports/jobs/{$job->id}")->assertStatus(404);
|
||
});
|
||
|
||
test('POST /api/reports/jobs: 422 без полей', function () {
|
||
$this->postJson('/api/reports/jobs', [])->assertStatus(422);
|
||
});
|
||
|
||
test('POST /api/reports/jobs: 422 неизвестный type', function () {
|
||
$this->postJson('/api/reports/jobs', [
|
||
'type' => 'unknown', 'format' => 'csv',
|
||
'parameters' => ['date_from' => '2026-04-01', 'date_to' => '2026-04-30'],
|
||
])->assertStatus(422);
|
||
});
|
||
|
||
test('POST /api/reports/jobs: 422 date_to < date_from', function () {
|
||
$this->postJson('/api/reports/jobs', [
|
||
'type' => 'deals_export', 'format' => 'csv',
|
||
'parameters' => ['date_from' => '2026-04-30', 'date_to' => '2026-04-01'],
|
||
])->assertStatus(422);
|
||
});
|
||
|
||
test('POST /api/reports/jobs: создаёт row + dispatch GenerateReportJob', function () {
|
||
Bus::fake();
|
||
|
||
$response = $this->postJson('/api/reports/jobs', [
|
||
'type' => 'deals_export', 'format' => 'csv',
|
||
'parameters' => ['date_from' => '2026-04-01', 'date_to' => '2026-04-30'],
|
||
]);
|
||
|
||
$response->assertStatus(201);
|
||
expect($response->json('job.type'))->toBe('deals_export');
|
||
expect($response->json('job.parameters.format'))->toBe('csv');
|
||
expect($response->json('job.status'))->toBe('pending');
|
||
|
||
Bus::assertDispatched(GenerateReportJob::class, function (GenerateReportJob $job) use ($response) {
|
||
return $job->reportJobId === $response->json('job.id');
|
||
});
|
||
});
|
||
|
||
test('POST /api/reports/jobs: квота 3 одновременных → 422 на 4-м', function () {
|
||
Bus::fake();
|
||
foreach (range(1, 3) as $_) {
|
||
makeReportJob($this->tenant->id, $this->user->id);
|
||
}
|
||
|
||
$response = $this->postJson('/api/reports/jobs', [
|
||
'type' => 'deals_export', 'format' => 'csv',
|
||
'parameters' => ['date_from' => '2026-04-01', 'date_to' => '2026-04-30'],
|
||
]);
|
||
|
||
$response->assertStatus(422);
|
||
expect($response->json('errors._quota.0'))->toContain('Максимум 3');
|
||
Bus::assertNotDispatched(GenerateReportJob::class);
|
||
});
|
||
|
||
test('POST /api/reports/jobs: квота не считает done/failed', function () {
|
||
Bus::fake();
|
||
makeReportJob($this->tenant->id, $this->user->id, ['status' => ReportJob::STATUS_DONE]);
|
||
makeReportJob($this->tenant->id, $this->user->id, ['status' => ReportJob::STATUS_DONE]);
|
||
makeReportJob($this->tenant->id, $this->user->id, ['status' => ReportJob::STATUS_FAILED]);
|
||
makeReportJob($this->tenant->id, $this->user->id); // pending = 1 active
|
||
|
||
$response = $this->postJson('/api/reports/jobs', [
|
||
'type' => 'deals_export', 'format' => 'csv',
|
||
'parameters' => ['date_from' => '2026-04-01', 'date_to' => '2026-04-30'],
|
||
]);
|
||
|
||
$response->assertStatus(201);
|
||
});
|
||
|
||
test('POST /api/reports/jobs: квота изолирована per tenant', function () {
|
||
Bus::fake();
|
||
$other = Tenant::factory()->create();
|
||
$otherUser = User::factory()->create(['tenant_id' => $other->id]);
|
||
foreach (range(1, 3) as $_) {
|
||
makeReportJob($other->id, $otherUser->id); // 3 у чужого тенанта
|
||
}
|
||
|
||
$response = $this->postJson('/api/reports/jobs', [
|
||
'type' => 'deals_export', 'format' => 'csv',
|
||
'parameters' => ['date_from' => '2026-04-01', 'date_to' => '2026-04-30'],
|
||
]);
|
||
|
||
$response->assertStatus(201);
|
||
});
|
||
|
||
test('POST /api/reports/jobs (sync queue): pending → done с file', function () {
|
||
config()->set('queue.default', 'sync');
|
||
|
||
// Партиции deals создаются ahead=2 от текущего месяца — используем NOW для гарантии.
|
||
$now = Carbon::now()->startOfMonth()->addDays(10);
|
||
$project = Project::factory()->create(['tenant_id' => $this->tenant->id]);
|
||
DB::table('deals')->insert([
|
||
'tenant_id' => $this->tenant->id,
|
||
'project_id' => $project->id,
|
||
'phone' => '+79991234567',
|
||
'contact_name' => 'Тест',
|
||
'status' => 'new',
|
||
'received_at' => $now,
|
||
'created_at' => Carbon::now(),
|
||
'updated_at' => Carbon::now(),
|
||
]);
|
||
|
||
$response = $this->postJson('/api/reports/jobs', [
|
||
'type' => 'deals_export', 'format' => 'csv',
|
||
'parameters' => [
|
||
'date_from' => $now->copy()->startOfMonth()->toDateString(),
|
||
'date_to' => $now->copy()->endOfMonth()->toDateString(),
|
||
],
|
||
]);
|
||
|
||
$response->assertStatus(201);
|
||
$jobId = $response->json('job.id');
|
||
$job = ReportJob::find($jobId);
|
||
expect($job->status)->toBe('done');
|
||
expect($job->file_path)->toBe("reports/{$this->tenant->id}/{$jobId}.csv");
|
||
expect($job->file_size)->toBeGreaterThan(0);
|
||
expect($job->finished_at)->not->toBeNull();
|
||
expect($job->expires_at)->not->toBeNull();
|
||
|
||
Storage::disk('local')->assertExists($job->file_path);
|
||
$content = Storage::disk('local')->get($job->file_path);
|
||
expect($content)->toContain('+79991234567');
|
||
expect($content)->toContain("\u{FEFF}"); // BOM для Excel
|
||
});
|
||
|
||
test('POST /api/reports/jobs (sync queue): xlsx → done с XLSX-файлом', function () {
|
||
config()->set('queue.default', 'sync');
|
||
|
||
$now = Carbon::now()->startOfMonth()->addDays(10);
|
||
$project = Project::factory()->create(['tenant_id' => $this->tenant->id]);
|
||
DB::table('deals')->insert([
|
||
'tenant_id' => $this->tenant->id,
|
||
'project_id' => $project->id,
|
||
'phone' => '+79992223344',
|
||
'contact_name' => 'XLSX Тест',
|
||
'status' => 'new',
|
||
'received_at' => $now,
|
||
'created_at' => Carbon::now(),
|
||
'updated_at' => Carbon::now(),
|
||
]);
|
||
|
||
$response = $this->postJson('/api/reports/jobs', [
|
||
'type' => 'deals_export', 'format' => 'xlsx',
|
||
'parameters' => [
|
||
'date_from' => $now->copy()->startOfMonth()->toDateString(),
|
||
'date_to' => $now->copy()->endOfMonth()->toDateString(),
|
||
],
|
||
]);
|
||
|
||
$response->assertStatus(201);
|
||
$job = ReportJob::find($response->json('job.id'));
|
||
expect($job->status)->toBe('done');
|
||
expect($job->file_path)->toEndWith('.xlsx');
|
||
expect($job->file_size)->toBeGreaterThan(2048);
|
||
|
||
Storage::disk('local')->assertExists($job->file_path);
|
||
$content = Storage::disk('local')->get($job->file_path);
|
||
// XLSX = ZIP (PK\x03\x04 magic bytes).
|
||
expect(substr($content, 0, 4))->toBe("PK\x03\x04");
|
||
});
|
||
|
||
test('POST /api/reports/jobs (sync queue): json → done с JSON-файлом', function () {
|
||
config()->set('queue.default', 'sync');
|
||
|
||
$now = Carbon::now()->startOfMonth()->addDays(10);
|
||
$project = Project::factory()->create(['tenant_id' => $this->tenant->id]);
|
||
DB::table('deals')->insert([
|
||
'tenant_id' => $this->tenant->id,
|
||
'project_id' => $project->id,
|
||
'phone' => '+79993334455',
|
||
'contact_name' => 'JSON Тест',
|
||
'status' => 'new',
|
||
'received_at' => $now,
|
||
'created_at' => Carbon::now(),
|
||
'updated_at' => Carbon::now(),
|
||
]);
|
||
|
||
$response = $this->postJson('/api/reports/jobs', [
|
||
'type' => 'deals_export', 'format' => 'json',
|
||
'parameters' => [
|
||
'date_from' => $now->copy()->startOfMonth()->toDateString(),
|
||
'date_to' => $now->copy()->endOfMonth()->toDateString(),
|
||
],
|
||
]);
|
||
|
||
$response->assertStatus(201);
|
||
$job = ReportJob::find($response->json('job.id'));
|
||
expect($job->status)->toBe('done');
|
||
expect($job->file_path)->toEndWith('.json');
|
||
|
||
$content = Storage::disk('local')->get($job->file_path);
|
||
$decoded = json_decode($content, true);
|
||
expect($decoded)->toHaveKey('headers');
|
||
expect($decoded)->toHaveKey('rows');
|
||
expect($decoded['headers'])->toContain('Телефон');
|
||
expect($decoded['rows'])->toHaveCount(1);
|
||
expect($decoded['rows'][0]['Телефон'])->toBe('+79993334455');
|
||
});
|
||
|
||
test('POST /api/reports/jobs (sync queue): pdf → failed (Post-MVP)', function () {
|
||
config()->set('queue.default', 'sync');
|
||
|
||
$response = $this->postJson('/api/reports/jobs', [
|
||
'type' => 'deals_export', 'format' => 'pdf',
|
||
'parameters' => ['date_from' => '2026-04-01', 'date_to' => '2026-04-30'],
|
||
]);
|
||
|
||
$response->assertStatus(201);
|
||
$job = ReportJob::find($response->json('job.id'));
|
||
expect($job->status)->toBe('failed');
|
||
expect($job->error_message)->toContain('Post-MVP');
|
||
});
|
||
|
||
test('POST /api/reports/jobs (sync queue): managers_summary → done с CSV', function () {
|
||
config()->set('queue.default', 'sync');
|
||
|
||
$now = Carbon::now()->startOfMonth()->addDays(10);
|
||
$project = Project::factory()->create(['tenant_id' => $this->tenant->id]);
|
||
$manager = User::factory()->create([
|
||
'tenant_id' => $this->tenant->id, 'first_name' => 'Иван', 'last_name' => 'Петров',
|
||
]);
|
||
DB::table('deals')->insert([
|
||
'tenant_id' => $this->tenant->id,
|
||
'project_id' => $project->id,
|
||
'manager_id' => $manager->id,
|
||
'phone' => '+79990001122',
|
||
'status' => 'won',
|
||
'received_at' => $now,
|
||
'created_at' => Carbon::now(),
|
||
'updated_at' => Carbon::now(),
|
||
]);
|
||
|
||
$response = $this->postJson('/api/reports/jobs', [
|
||
'type' => 'managers_summary', 'format' => 'csv',
|
||
'parameters' => [
|
||
'date_from' => $now->copy()->startOfMonth()->toDateString(),
|
||
'date_to' => $now->copy()->endOfMonth()->toDateString(),
|
||
],
|
||
]);
|
||
|
||
$response->assertStatus(201);
|
||
$job = ReportJob::find($response->json('job.id'));
|
||
expect($job->status)->toBe('done');
|
||
expect($job->file_path)->toEndWith('.csv');
|
||
|
||
$content = Storage::disk('local')->get($job->file_path);
|
||
expect($content)->toContain('Менеджер');
|
||
expect($content)->toContain('Иван Петров');
|
||
});
|
||
|
||
test('POST /api/reports/jobs (sync queue): sources_summary → done с CSV', function () {
|
||
config()->set('queue.default', 'sync');
|
||
|
||
$now = Carbon::now()->startOfMonth()->addDays(10);
|
||
$project = Project::factory()->create(['tenant_id' => $this->tenant->id]);
|
||
DB::table('deals')->insert([
|
||
'tenant_id' => $this->tenant->id,
|
||
'project_id' => $project->id,
|
||
'phone' => '+79990002233',
|
||
'status' => 'won',
|
||
'utm_source' => 'yandex',
|
||
'received_at' => $now,
|
||
'created_at' => Carbon::now(),
|
||
'updated_at' => Carbon::now(),
|
||
]);
|
||
|
||
$response = $this->postJson('/api/reports/jobs', [
|
||
'type' => 'sources_summary', 'format' => 'csv',
|
||
'parameters' => [
|
||
'date_from' => $now->copy()->startOfMonth()->toDateString(),
|
||
'date_to' => $now->copy()->endOfMonth()->toDateString(),
|
||
],
|
||
]);
|
||
|
||
$response->assertStatus(201);
|
||
$job = ReportJob::find($response->json('job.id'));
|
||
expect($job->status)->toBe('done');
|
||
expect($job->file_path)->toEndWith('.csv');
|
||
|
||
$content = Storage::disk('local')->get($job->file_path);
|
||
expect($content)->toContain('Источник');
|
||
expect($content)->toContain('yandex');
|
||
});
|
||
|
||
test('POST /api/reports/jobs (sync queue): billing_summary → done с CSV', function () {
|
||
config()->set('queue.default', 'sync');
|
||
|
||
BalanceTransaction::create([
|
||
'tenant_id' => $this->tenant->id,
|
||
'type' => 'topup',
|
||
'amount_rub' => 5000,
|
||
'amount_leads' => 0,
|
||
'description' => 'test topup',
|
||
'created_at' => Carbon::now()->startOfMonth()->addDays(5),
|
||
]);
|
||
|
||
$response = $this->postJson('/api/reports/jobs', [
|
||
'type' => 'billing_summary', 'format' => 'csv',
|
||
'parameters' => [
|
||
'date_from' => Carbon::now()->startOfMonth()->toDateString(),
|
||
'date_to' => Carbon::now()->endOfMonth()->toDateString(),
|
||
],
|
||
]);
|
||
|
||
$response->assertStatus(201);
|
||
$job = ReportJob::find($response->json('job.id'));
|
||
expect($job->status)->toBe('done');
|
||
expect($job->file_path)->toEndWith('.csv');
|
||
|
||
$content = Storage::disk('local')->get($job->file_path);
|
||
expect($content)->toContain('Тип операции');
|
||
expect($content)->toContain('Пополнение');
|
||
});
|