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('Неподдерживаемая'); });