Files
portal/app/tests/Feature/Reports/ReportJobControllerTest.php
T
Дмитрий 19f319cd5d phase2(reports-stage1): ReportJob model + GenerateReportJob + API + deals_export csv
- ReportJob Eloquent (schema §13.5 report_jobs): status pending/processing/done/failed,
  parameters JSONB (format/date_from/date_to/project_id?/manager_id?), constants
  TYPES + FORMATS, helpers isActive/isDone/isFailed.
- ReportJobFactory + states processing/done/failed.
- App\Services\Reports\* пакет: ReportGenerator interface, GenerationResult DTO,
  ReportGeneratorRegistry с резолвом по (type,format), DealsExportCsvGenerator
  (Excel-friendly CSV: BOM, ; separator, \r\n, escape; deals JOIN projects/users/
  supplier_lead_costs за date_from..date_to, soft-deleted скрыты).
- App\Jobs\GenerateReportJob: tries=1 (auto-retry отключён, retry через UI кнопку
  CTO-6); меняет status pending → processing → done|failed, заполняет file_path/
  file_size/generation_seconds/finished_at/expires_at (=NOW+30д).
- App\Http\Controllers\Api\ReportJobController под auth:sanctum:
  - GET /api/reports/jobs?status=&limit=&offset= → jobs+total+counts+quota
  - GET /api/reports/jobs/{id}
  - POST /api/reports/jobs (квота CTO-7: max 3 active per tenant → 422)
  - dispatch GenerateReportJob (sync на dev → файл готов сразу).
- Storage local-disk на dev (storage/app/reports/{tenant_id}/{job_id}.csv);
  на prod заменим на s3 (Yandex Object Storage) отдельным коммитом.
- Pest +20 в tests/Feature/Reports/ReportJobControllerTest.php (всего 379/379,
  1280 assertions): 401 без auth / GET пустой+only-own+ORDER+filter+counts+limit/
  show success+404 own/foreign / store 422 (без полей/неизвестный type/date_to<from)/
  dispatch / sync queue → done с file (BOM проверен) / unsupported format → failed/
  квота 3 → 422 на 4-м / квота не считает done+failed / квота per-tenant.
- phpstan-baseline регенерирован (+1 ignored для Factory typing).

Этап 1/4 эпика Reports backend (закрыт). Этап 2: 4 типа × 4 формата.

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

264 lines
10 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): unsupported format → failed', function () {
config()->set('queue.default', 'sync');
$response = $this->postJson('/api/reports/jobs', [
'type' => 'deals_export', 'format' => 'xlsx', // этап 1: только 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('Неподдерживаемая');
});