Files
portal/app/tests/Feature/Reports/ReportJobControllerTest.php
T
2026-05-16 12:21:51 +03:00

409 lines
16 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\Jobs\GenerateReportJob;
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' => 'paid',
'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' => 'paid',
'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');
});