Files
portal/app/tests/Feature/Reports/ReportLifecycleTest.php
T
Дмитрий 9765ed760d phase2(reports-stage3): retry/cancel/destroy + reports:cleanup-expired cron
- ReportJobController +3 endpoints под auth:sanctum:
  - POST /api/reports/jobs/{id}/retry — CTO-6: только owner+failed, max 3 попытки
    (parameters.retry_count), окно 7 дней с created_at, квота CTO-7 учитывается;
    создаёт НОВЫЙ ReportJob (parameters.retry_of=original.id) + dispatch.
  - POST /api/reports/jobs/{id}/cancel — только owner+pending; status=failed +
    error_message=«Отменено пользователем.» + finished_at=NOW.
  - DELETE /api/reports/jobs/{id} — только owner+terminal (done|failed); удаляет
    файл из disk('local') + row.
- toResource +3 поля: is_expired (expires_at < NOW), retry_count, retry_max=3.
- App\Console\Commands\ReportsCleanupExpired (cron `reports:cleanup-expired`):
  где status='done' AND expires_at < NOW AND file_path IS NOT NULL → delete file
  + UPDATE file_path=NULL. CTO-10: status='done' СОХРАНЯЕТСЯ. failed-jobs
  игнорируются. --dry-run + --limit=1000. Запуск ежесуточно через Task Scheduler.
- routes/web.php: новые 3 routes под существующим prefix /api/reports/jobs.
- Pest +21 в ReportLifecycleTest.php (всего 403/403, +21 от 382, 1343 assertions):
  retry 8 (404 unknown/foreign / 403 не владелец / 422 не failed / success+new+
  retry_count=1+retry_of / 422 max retries / 422 окно 7 дней / 422 квота 3) +
  cancel 4 (404 / 422 не pending / success / 403 не владелец) + destroy 5
  (404 / 422 pending / 403 не владелец / success+file / success+file_path=NULL)
  + index +1 (is_expired/retry_count/retry_max в response) + cron 3 (удаление
  expired+CTO-10 status сохраняется / --dry-run / failed игнорируются).
- phpstan-baseline регенерирован.

Этап 3/4 эпика Reports backend (закрыт). Этап 4: frontend integration —
заменить mock в ReportsView на реальный API + UI кнопки retry/cancel/delete.

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

238 lines
9.6 KiB
PHP

<?php
declare(strict_types=1);
use App\Jobs\GenerateReportJob;
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 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'); // НЕ затронут
});