tenant = Tenant::factory()->create(); $this->user = User::factory()->create(['tenant_id' => $this->tenant->id]); $this->actingAs($this->user); Storage::fake('local'); }); function makeJob(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)); } // ------ retry ------ test('POST /retry: 404 unknown', function () { $this->postJson('/api/reports/jobs/999999/retry')->assertStatus(404); }); test('POST /retry: 404 чужой', function () { $other = Tenant::factory()->create(); $otherUser = User::factory()->create(['tenant_id' => $other->id]); $job = makeJob($other->id, $otherUser->id, ['status' => ReportJob::STATUS_FAILED]); $this->postJson("/api/reports/jobs/{$job->id}/retry")->assertStatus(404); }); test('POST /retry: 403 не владелец (тот же tenant)', function () { $other = User::factory()->create(['tenant_id' => $this->tenant->id]); $job = makeJob($this->tenant->id, $other->id, ['status' => ReportJob::STATUS_FAILED]); $this->postJson("/api/reports/jobs/{$job->id}/retry")->assertStatus(403); }); test('POST /retry: 422 если status != failed', function () { $job = makeJob($this->tenant->id, $this->user->id, ['status' => ReportJob::STATUS_DONE]); $response = $this->postJson("/api/reports/jobs/{$job->id}/retry"); $response->assertStatus(422); expect($response->json('errors.status.0'))->toContain('done'); }); test('POST /retry: success — создаёт NEW job + dispatch + retry_count=1', function () { Bus::fake(); $original = makeJob($this->tenant->id, $this->user->id, ['status' => ReportJob::STATUS_FAILED]); $response = $this->postJson("/api/reports/jobs/{$original->id}/retry"); $response->assertStatus(201); expect($response->json('job.id'))->not->toBe($original->id); expect($response->json('job.status'))->toBe('pending'); expect($response->json('job.retry_count'))->toBe(1); expect($response->json('job.parameters.retry_of'))->toBe($original->id); Bus::assertDispatched(GenerateReportJob::class); }); test('POST /retry: 422 после 3 попыток (CTO-6)', function () { Bus::fake(); $job = makeJob($this->tenant->id, $this->user->id, [ 'status' => ReportJob::STATUS_FAILED, 'parameters' => ['format' => 'csv', 'date_from' => '2026-04-01', 'date_to' => '2026-04-30', 'retry_count' => 2], ]); $response = $this->postJson("/api/reports/jobs/{$job->id}/retry"); $response->assertStatus(422); expect($response->json('errors._retry.0'))->toContain('Максимум 3'); }); test('POST /retry: 422 если старше 7 дней', function () { Bus::fake(); $job = makeJob($this->tenant->id, $this->user->id, ['status' => ReportJob::STATUS_FAILED]); DB::table('report_jobs')->where('id', $job->id)->update(['created_at' => Carbon::now()->subDays(8)]); $response = $this->postJson("/api/reports/jobs/{$job->id}/retry"); $response->assertStatus(422); expect($response->json('errors._retry.0'))->toContain('7 дней'); }); test('POST /retry: 422 если квота 3 уже исчерпана', function () { Bus::fake(); $failed = makeJob($this->tenant->id, $this->user->id, ['status' => ReportJob::STATUS_FAILED]); foreach (range(1, 3) as $_) { makeJob($this->tenant->id, $this->user->id); } $response = $this->postJson("/api/reports/jobs/{$failed->id}/retry"); $response->assertStatus(422); expect($response->json('errors._quota.0'))->toContain('Максимум 3'); }); // ------ cancel ------ test('POST /cancel: 404 unknown', function () { $this->postJson('/api/reports/jobs/999999/cancel')->assertStatus(404); }); test('POST /cancel: 422 если не pending', function () { $job = makeJob($this->tenant->id, $this->user->id, ['status' => ReportJob::STATUS_DONE]); $response = $this->postJson("/api/reports/jobs/{$job->id}/cancel"); $response->assertStatus(422); }); test('POST /cancel: success → status=failed + Отменено пользователем', function () { $job = makeJob($this->tenant->id, $this->user->id); $response = $this->postJson("/api/reports/jobs/{$job->id}/cancel"); $response->assertStatus(200); expect($response->json('job.status'))->toBe('failed'); expect($response->json('job.error_message'))->toBe('Отменено пользователем.'); expect($response->json('job.finished_at'))->not->toBeNull(); }); test('POST /cancel: 403 не владелец', function () { $other = User::factory()->create(['tenant_id' => $this->tenant->id]); $job = makeJob($this->tenant->id, $other->id); $this->postJson("/api/reports/jobs/{$job->id}/cancel")->assertStatus(403); }); // ------ destroy ------ test('DELETE /jobs/{id}: 404 unknown', function () { $this->deleteJson('/api/reports/jobs/999999')->assertStatus(404); }); test('DELETE /jobs/{id}: 422 если pending (не terminal)', function () { $job = makeJob($this->tenant->id, $this->user->id); $this->deleteJson("/api/reports/jobs/{$job->id}")->assertStatus(422); }); test('DELETE /jobs/{id}: 403 не владелец', function () { $other = User::factory()->create(['tenant_id' => $this->tenant->id]); $job = makeJob($this->tenant->id, $other->id, ['status' => ReportJob::STATUS_DONE]); $this->deleteJson("/api/reports/jobs/{$job->id}")->assertStatus(403); }); test('DELETE /jobs/{id}: success → row + file deleted', function () { Storage::disk('local')->put('reports/test/123.csv', 'data'); $job = makeJob($this->tenant->id, $this->user->id, [ 'status' => ReportJob::STATUS_DONE, 'file_path' => 'reports/test/123.csv', ]); $this->deleteJson("/api/reports/jobs/{$job->id}")->assertStatus(200); Storage::disk('local')->assertMissing('reports/test/123.csv'); expect(ReportJob::find($job->id))->toBeNull(); }); test('DELETE /jobs/{id}: success даже если file_path=NULL (expired)', function () { $job = makeJob($this->tenant->id, $this->user->id, [ 'status' => ReportJob::STATUS_DONE, 'file_path' => null, ]); $this->deleteJson("/api/reports/jobs/{$job->id}")->assertStatus(200); expect(ReportJob::find($job->id))->toBeNull(); }); // ------ index includes is_expired + retry_count ------ test('GET /jobs: response содержит is_expired + retry_count + retry_max', function () { makeJob($this->tenant->id, $this->user->id, [ 'status' => ReportJob::STATUS_DONE, 'expires_at' => Carbon::now()->subDay(), // expired 'parameters' => ['format' => 'csv', 'date_from' => '2026-04-01', 'date_to' => '2026-04-30', 'retry_count' => 2], ]); $response = $this->getJson('/api/reports/jobs'); expect($response->json('jobs.0.is_expired'))->toBeTrue(); expect($response->json('jobs.0.retry_count'))->toBe(2); expect($response->json('jobs.0.retry_max'))->toBe(3); }); // ------ cleanup-expired cron ------ test('reports:cleanup-expired: удаляет file_path expired done-jobs', function () { Storage::disk('local')->put('reports/exp/1.csv', 'a'); Storage::disk('local')->put('reports/exp/2.csv', 'b'); $expired = makeJob($this->tenant->id, $this->user->id, [ 'status' => ReportJob::STATUS_DONE, 'file_path' => 'reports/exp/1.csv', 'expires_at' => Carbon::now()->subDay(), ]); $alive = makeJob($this->tenant->id, $this->user->id, [ 'status' => ReportJob::STATUS_DONE, 'file_path' => 'reports/exp/2.csv', 'expires_at' => Carbon::now()->addDay(), ]); $this->artisan('reports:cleanup-expired')->assertExitCode(0); Storage::disk('local')->assertMissing('reports/exp/1.csv'); Storage::disk('local')->assertExists('reports/exp/2.csv'); expect(ReportJob::find($expired->id)->file_path)->toBeNull(); expect(ReportJob::find($expired->id)->status)->toBe('done'); // CTO-10: статус НЕ меняется expect(ReportJob::find($alive->id)->file_path)->toBe('reports/exp/2.csv'); }); test('reports:cleanup-expired --dry-run: ничего не удаляет', function () { Storage::disk('local')->put('reports/exp/3.csv', 'c'); $job = makeJob($this->tenant->id, $this->user->id, [ 'status' => ReportJob::STATUS_DONE, 'file_path' => 'reports/exp/3.csv', 'expires_at' => Carbon::now()->subDay(), ]); $this->artisan('reports:cleanup-expired --dry-run')->assertExitCode(0); Storage::disk('local')->assertExists('reports/exp/3.csv'); expect(ReportJob::find($job->id)->file_path)->toBe('reports/exp/3.csv'); }); test('reports:cleanup-expired: failed-jobs игнорируются (даже если expired)', function () { $job = makeJob($this->tenant->id, $this->user->id, [ 'status' => ReportJob::STATUS_FAILED, 'file_path' => 'reports/x/1.csv', 'expires_at' => Carbon::now()->subDay(), ]); $this->artisan('reports:cleanup-expired')->assertExitCode(0); expect(ReportJob::find($job->id)->file_path)->toBe('reports/x/1.csv'); // НЕ затронут });