목록 조회 성능 축(깊은 OFFSET·정렬·색인)과 그 검증 계층에서 계획서 전수검수와 3회에 걸친 감사가 지적한 항목을 처리했다. 세부 경위는 의 각 회차 문서에 있다. 검증 계층 — 컨트롤러가 base Request 를 직접 주입받던 확장 13곳을 전용 FormRequest 로 옮겼다. 옮기면서 기존 동작 계약은 그대로 두었다: 상한 초과 limit 을 거부하지 않고 상한까지 반환하던 공개 API, per_page 를 범위로 조정하던 관리자 목록, 미지원 period 를 year 로 해석하던 인기글 목록 모두 종전과 같은 응답을 낸다. 상한/폴백을 rules 로 승격시키면 200 이던 응답이 422 가 되어 기존 링크가 깨지므로, 규칙은 타입만 닫고 클램프·폴백은 접근자가 맡는다. period 는 접근자가 닫힌 집합만 반환해 캐시 키 공간도 함께 닫힌다. ckeditor5 이미지 업로드만 ResponseHelper 봉투를 쓰지 않는다. 응답을 파싱하는 주체가 CDN 으로 로드되는 상위 CKEditor5 43.3.1 의 SimpleUploadAdapter 라 규약을 바꿀 수 없어, 각 응답 지점에 사유를 명시한 면제를 부착하고 근거를 API 문서에 남겼다. 하네스 — 룰 5개의 대상 경로 패턴이 매처와 맞지 않아 번들 확장 컨트롤러가 검사 대상에서 통째로 빠져 있었다. 패턴을 고치자 확장 위반 17건이 드러나 전건 처리했다. severity 오타가 요약 집계 양쪽에 안 잡혀 "0 error" 로 보고되던 문제도 런너 사전 검증으로 막았다. 정렬 게이트 대조 하네스는 관계 정렬 변형만 쓰는 저장소를 탐지하지 못한 채 통과시키고 있었다. 탐지·제외·인자 파싱을 함께 고치고 단위 테스트를 신설했다.
333 lines
12 KiB
PHP
333 lines
12 KiB
PHP
<?php
|
|
|
|
namespace Tests\Feature\Api\Admin;
|
|
|
|
use App\Models\Permission;
|
|
use App\Models\Role;
|
|
use App\Models\Schedule;
|
|
use App\Models\ScheduleHistory;
|
|
use App\Models\User;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
use Tests\TestCase;
|
|
|
|
/**
|
|
* ScheduleController 일괄 작업 Feature 테스트
|
|
*
|
|
* 스케줄 일괄 상태 변경 및 일괄 삭제 API 엔드포인트 테스트
|
|
* Issue #74: exists 검증 규칙이 Rule::exists(Schedule::class, 'id')로 올바르게 동작하는지 검증
|
|
*/
|
|
class ScheduleControllerTest extends TestCase
|
|
{
|
|
use RefreshDatabase;
|
|
|
|
private User $admin;
|
|
|
|
private string $token;
|
|
|
|
protected function setUp(): void
|
|
{
|
|
parent::setUp();
|
|
|
|
$this->admin = $this->createAdminUser();
|
|
$this->token = $this->admin->createToken('test-token')->plainTextToken;
|
|
}
|
|
|
|
/**
|
|
* 관리자 사용자 생성 헬퍼
|
|
*
|
|
* @param array $permissions 권한 식별자 목록
|
|
*/
|
|
private function createAdminUser(array $permissions = [
|
|
'core.schedules.read',
|
|
'core.schedules.create',
|
|
'core.schedules.update',
|
|
'core.schedules.delete',
|
|
]): User
|
|
{
|
|
$user = User::factory()->create();
|
|
|
|
$permissionIds = [];
|
|
foreach ($permissions as $permIdentifier) {
|
|
$permission = Permission::firstOrCreate(
|
|
['identifier' => $permIdentifier],
|
|
[
|
|
'name' => json_encode(['ko' => $permIdentifier, 'en' => $permIdentifier]),
|
|
'description' => json_encode(['ko' => $permIdentifier, 'en' => $permIdentifier]),
|
|
'extension_type' => 'core',
|
|
'extension_identifier' => 'core',
|
|
'type' => 'admin',
|
|
]
|
|
);
|
|
$permissionIds[] = $permission->id;
|
|
}
|
|
|
|
$roleIdentifier = 'admin_test_'.uniqid();
|
|
$testRole = Role::create([
|
|
'identifier' => $roleIdentifier,
|
|
'name' => json_encode(['ko' => '테스트 관리자', 'en' => 'Test Admin']),
|
|
'is_active' => true,
|
|
]);
|
|
|
|
$adminRole = Role::firstOrCreate(
|
|
['identifier' => 'admin'],
|
|
[
|
|
'name' => json_encode(['ko' => '관리자', 'en' => 'Administrator']),
|
|
'extension_type' => 'core',
|
|
'extension_identifier' => 'core',
|
|
'is_active' => true,
|
|
]
|
|
);
|
|
|
|
$testRole->permissions()->sync($permissionIds);
|
|
|
|
$user->roles()->attach($adminRole->id, ['assigned_at' => now()]);
|
|
$user->roles()->attach($testRole->id, ['assigned_at' => now()]);
|
|
|
|
return $user->fresh();
|
|
}
|
|
|
|
/**
|
|
* 인증된 요청 헬퍼
|
|
*/
|
|
private function authRequest(): static
|
|
{
|
|
return $this->withHeaders([
|
|
'Authorization' => 'Bearer '.$this->token,
|
|
'Accept' => 'application/json',
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* 테스트용 스케줄 생성 헬퍼
|
|
*
|
|
* @param array $attributes 오버라이드할 속성
|
|
*/
|
|
private function createSchedule(array $attributes = []): Schedule
|
|
{
|
|
return Schedule::create(array_merge([
|
|
'name' => '테스트 스케줄',
|
|
'description' => '테스트용 스케줄입니다',
|
|
'type' => 'artisan',
|
|
'command' => 'inspire',
|
|
'expression' => '* * * * *',
|
|
'frequency' => 'everyMinute',
|
|
'is_active' => true,
|
|
'created_by' => $this->admin->id,
|
|
], $attributes));
|
|
}
|
|
|
|
// ────────────────────────────────────────────────────────
|
|
// 실행 이력 목록 — 관계 로드 (지연 조인 계약)
|
|
// ────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* 실행 이력 목록이 실행자(triggered_by)를 함께 내려주는지 검증합니다.
|
|
*
|
|
* 지연 조인 트레이트는 inner 뿐 아니라 outer 에서도 `setEagerLoads([])` 로 eager load 를
|
|
* 지운다. 따라서 저장소가 관계를 `$query->with()` 로 붙이고 `relations:` 인자를 넘기지
|
|
* 않으면 관계가 조용히 사라지고, Resource 의 `whenLoaded('triggeredBy')` 가 항상 비어
|
|
* `triggered_by` 키 자체가 응답에서 없어진다. 예외도 쿼리 오류도 나지 않으므로 이 단언이
|
|
* 없으면 화면에서 실행자 칸이 빈 것으로만 드러난다.
|
|
*/
|
|
public function test_history_list_includes_triggered_by_relation(): void
|
|
{
|
|
$schedule = $this->createSchedule();
|
|
|
|
ScheduleHistory::create([
|
|
'schedule_id' => $schedule->id,
|
|
'started_at' => now()->subMinute(),
|
|
'ended_at' => now(),
|
|
'duration' => 1,
|
|
'status' => 'success',
|
|
'exit_code' => 0,
|
|
'trigger_type' => 'manual',
|
|
'triggered_by' => $this->admin->id,
|
|
]);
|
|
|
|
$response = $this->authRequest()
|
|
->getJson("/api/admin/schedules/{$schedule->id}/history");
|
|
|
|
$response->assertStatus(200);
|
|
|
|
$rows = $response->json('data.data') ?? $response->json('data');
|
|
$this->assertNotEmpty($rows, '실행 이력이 최소 1건 조회되어야 한다');
|
|
|
|
$this->assertArrayHasKey(
|
|
'triggered_by',
|
|
$rows[0],
|
|
'triggered_by 키가 없다 — 저장소가 relations: 로 관계를 넘기지 않아 eager load 가 지워졌을 수 있다'
|
|
);
|
|
$this->assertSame($this->admin->name, $rows[0]['triggered_by']['name']);
|
|
$this->assertSame($this->admin->uuid, $rows[0]['triggered_by']['uuid']);
|
|
}
|
|
|
|
// ────────────────────────────────────────────────────────
|
|
// Bulk Update Status (BulkUpdateScheduleStatusRequest)
|
|
// ────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* 일괄 상태 변경 성공 테스트
|
|
* (Rule::exists(Schedule::class, 'id') 검증)
|
|
*/
|
|
public function test_bulk_update_status_activates_schedules(): void
|
|
{
|
|
$schedule1 = $this->createSchedule(['name' => '스케줄 1', 'is_active' => false]);
|
|
$schedule2 = $this->createSchedule(['name' => '스케줄 2', 'is_active' => false]);
|
|
|
|
$response = $this->authRequest()
|
|
->patchJson('/api/admin/schedules/bulk-status', [
|
|
'ids' => [$schedule1->id, $schedule2->id],
|
|
'is_active' => true,
|
|
]);
|
|
|
|
$response->assertStatus(200);
|
|
$this->assertDatabaseHas('schedules', ['id' => $schedule1->id, 'is_active' => true]);
|
|
$this->assertDatabaseHas('schedules', ['id' => $schedule2->id, 'is_active' => true]);
|
|
}
|
|
|
|
/**
|
|
* 일괄 상태 변경 - 비활성화 테스트
|
|
*/
|
|
public function test_bulk_update_status_deactivates_schedules(): void
|
|
{
|
|
$schedule1 = $this->createSchedule(['name' => '스케줄 1', 'is_active' => true]);
|
|
$schedule2 = $this->createSchedule(['name' => '스케줄 2', 'is_active' => true]);
|
|
|
|
$response = $this->authRequest()
|
|
->patchJson('/api/admin/schedules/bulk-status', [
|
|
'ids' => [$schedule1->id, $schedule2->id],
|
|
'is_active' => false,
|
|
]);
|
|
|
|
$response->assertStatus(200);
|
|
$this->assertDatabaseHas('schedules', ['id' => $schedule1->id, 'is_active' => false]);
|
|
$this->assertDatabaseHas('schedules', ['id' => $schedule2->id, 'is_active' => false]);
|
|
}
|
|
|
|
/**
|
|
* 존재하지 않는 ID로 일괄 상태 변경 시 검증 실패 테스트
|
|
*/
|
|
public function test_bulk_update_status_fails_with_nonexistent_ids(): void
|
|
{
|
|
$response = $this->authRequest()
|
|
->patchJson('/api/admin/schedules/bulk-status', [
|
|
'ids' => [99999, 99998],
|
|
'is_active' => true,
|
|
]);
|
|
|
|
$response->assertStatus(422);
|
|
$response->assertJsonValidationErrors(['ids.0', 'ids.1']);
|
|
}
|
|
|
|
/**
|
|
* 빈 배열로 일괄 상태 변경 시 검증 실패 테스트
|
|
*/
|
|
public function test_bulk_update_status_fails_with_empty_ids(): void
|
|
{
|
|
$response = $this->authRequest()
|
|
->patchJson('/api/admin/schedules/bulk-status', [
|
|
'ids' => [],
|
|
'is_active' => true,
|
|
]);
|
|
|
|
$response->assertStatus(422);
|
|
$response->assertJsonValidationErrors(['ids']);
|
|
}
|
|
|
|
/**
|
|
* is_active 필드 누락 시 검증 실패 테스트
|
|
*/
|
|
public function test_bulk_update_status_fails_without_is_active(): void
|
|
{
|
|
$schedule = $this->createSchedule();
|
|
|
|
$response = $this->authRequest()
|
|
->patchJson('/api/admin/schedules/bulk-status', [
|
|
'ids' => [$schedule->id],
|
|
]);
|
|
|
|
$response->assertStatus(422);
|
|
$response->assertJsonValidationErrors(['is_active']);
|
|
}
|
|
|
|
// ────────────────────────────────────────────────────────
|
|
// Bulk Delete (BulkDeleteScheduleRequest)
|
|
// ────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* 일괄 삭제 성공 테스트
|
|
* (Rule::exists(Schedule::class, 'id') 검증)
|
|
*/
|
|
public function test_bulk_delete_removes_schedules_successfully(): void
|
|
{
|
|
$schedule1 = $this->createSchedule(['name' => '삭제 대상 1']);
|
|
$schedule2 = $this->createSchedule(['name' => '삭제 대상 2']);
|
|
|
|
$response = $this->authRequest()
|
|
->deleteJson('/api/admin/schedules/bulk', [
|
|
'ids' => [$schedule1->id, $schedule2->id],
|
|
]);
|
|
|
|
$response->assertStatus(200);
|
|
$this->assertDatabaseMissing('schedules', ['id' => $schedule1->id]);
|
|
$this->assertDatabaseMissing('schedules', ['id' => $schedule2->id]);
|
|
}
|
|
|
|
/**
|
|
* 존재하지 않는 ID로 일괄 삭제 시 검증 실패 테스트
|
|
*/
|
|
public function test_bulk_delete_fails_with_nonexistent_ids(): void
|
|
{
|
|
$response = $this->authRequest()
|
|
->deleteJson('/api/admin/schedules/bulk', [
|
|
'ids' => [99999],
|
|
]);
|
|
|
|
$response->assertStatus(422);
|
|
$response->assertJsonValidationErrors(['ids.0']);
|
|
}
|
|
|
|
/**
|
|
* 빈 배열로 일괄 삭제 시 검증 실패 테스트
|
|
*/
|
|
public function test_bulk_delete_fails_with_empty_ids(): void
|
|
{
|
|
$response = $this->authRequest()
|
|
->deleteJson('/api/admin/schedules/bulk', [
|
|
'ids' => [],
|
|
]);
|
|
|
|
$response->assertStatus(422);
|
|
$response->assertJsonValidationErrors(['ids']);
|
|
}
|
|
|
|
// ────────────────────────────────────────────────────────
|
|
// 인증 테스트
|
|
// ────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* 미인증 사용자 일괄 상태 변경 거부 테스트
|
|
*/
|
|
public function test_unauthenticated_bulk_update_status_gets_401(): void
|
|
{
|
|
$response = $this->patchJson('/api/admin/schedules/bulk-status', [
|
|
'ids' => [1],
|
|
'is_active' => true,
|
|
]);
|
|
|
|
$response->assertStatus(401);
|
|
}
|
|
|
|
/**
|
|
* 미인증 사용자 일괄 삭제 거부 테스트
|
|
*/
|
|
public function test_unauthenticated_bulk_delete_gets_401(): void
|
|
{
|
|
$response = $this->deleteJson('/api/admin/schedules/bulk', [
|
|
'ids' => [1],
|
|
]);
|
|
|
|
$response->assertStatus(401);
|
|
}
|
|
}
|