9765ed760d
- 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>
238 lines
9.6 KiB
PHP
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'); // НЕ затронут
|
|
});
|