Files
portal/app/tests/Feature/Reports/ReportJobControllerTest.php
T
Дмитрий 1a6a74c1a0 phase2(reports-stage2): provider+formatter архитектура + XLSX/JSON/PDF-stub
- Реструктура Services/Reports: вместо `Generator` per (type×format) комбинации
  (16 классов) разделено на 4 Providers + 4 Formatters (8 классов).
- App\Services\Reports\Providers\ReportDataProvider interface + DealsExportProvider
  (вынесен из старого DealsExportCsvGenerator; возвращает headers + rows).
- App\Services\Reports\Formatters\ReportFormatter interface + 4 реализации:
  - CsvFormatter — Excel-friendly (BOM + ; + \r\n + escape).
  - XlsxFormatter — PhpSpreadsheet 5.x (A1-нотация + bold headers + auto-size cols).
  - JsonFormatter — pretty + UNESCAPED_UNICODE (кириллица в исходном виде).
  - PdfStubFormatter — Post-MVP, throw RuntimeException.
- ReportGeneratorRegistry перепаспортирован: provider(type) + formatter(format).
- GenerateReportJob: вызывает provider->headers/rows + formatter->format вместо
  старого generator->generate.
- Удалено: DealsExportCsvGenerator, ReportGenerator interface, GenerationResult DTO.
- Pest +3 (всего 382/382, +3 от 379, 1297 assertions): xlsx → done с XLSX-magic-bytes
  PK\x03\x04; json → done + decoded ['rows', 'headers']; pdf → failed «Post-MVP»;
  managers_summary (не реализован) → failed.
- phpstan-baseline регенерирован.

Этап 2/4 эпика Reports backend (закрыт). Этап 2b: 3 оставшихся типа провайдеров
(managers_summary / sources_summary / billing_summary) — каждый × 4 формата без
изменений в архитектуре. Этап 3: retry/cancel/delete + retention cron.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 13:39:32 +03:00

352 lines
14 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 не реализован → failed', function () {
config()->set('queue.default', 'sync');
$response = $this->postJson('/api/reports/jobs', [
'type' => 'managers_summary', 'format' => 'csv',
'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('Неподдерживаемая');
});