공개 이슈 gnuboard/g7 이 지목한 병목은 "같은 일을 반복 실행한다" 축이다. 응답·행의 무게를 다룬 · 과 겹치지 않는다. 공통 계약 — 총 건수 상한과 페이지 이동 범위는 별개 결정이다. 묶으면 필요 없이 기능이 깎인다. 총 건수만 상한을 받고(파생 테이블 COUNT), "다음" 이동은 per_page + 1 실측으로 끝까지 열어 둔다. 계산이 불가능해지는 것은 마지막 페이지 번호 하나뿐이며 그 사실은 last_page: null 이 알린다. 최신순처럼 실제 컬럼으로 정렬하는 목록은 커서로 전환해 깊이와 무관하게 일정 속도로 이동한다. 관련도순은 계산값 정렬이라 커서 키로 쓸 수 없어 offset 을 유지한다. 계약의 입구는 표준 paginate 와 같은 폭이어야 한다. 관계·쿼리 빌더를 받지 못하면 그 좁은 만큼이 그대로 운영 500 이 되고, 실제로 관리자 알림 목록에서 그렇게 터졌다. 응답 조립도 컬렉션마다 손으로 하면 형태가 늘어나는 순간 없는 값을 부르거나 새 필드를 흘리므로, 형태 판정을 paginationMeta 한 곳에 모았다. 표준 paginate 응답은 필드 단위로 이전과 동일하다. 요청당 반복 비용 — 훅 구독마다 남기던 로그 400줄, 요청당 스무 번 넘던 설정 파일 재읽기, 이미 캐시된 목록의 DB 재조회를 없앴다. 권한 판정은 요청 스코프 메모를 두어 화면 요소마다 나가던 조회를 한 번으로 줄였고, 크로스 요청 캐시는 두지 않아 권한 변경이 종전처럼 다음 요청에 반영된다. 검색 질의는 활성 엔진이 만든다. 저장소가 구체 엔진을 지목하면 플러그인이 등록한 엔진은 호출될 기회 자체를 잃고 오류 없이 다른 방식으로 동작한다. 해석기를 두어 활성 엔진에 위임하고, 전문검색이 없는 DBMS 의 부분일치 폴백도 드라이버명 하드코딩 대신 선언형 config 로 옮겼다. 총 건수가 잘린 목록에서 순번을 역산하면 0 과 음수가 나온다. 지어내지 않고 null 을 돌려주며, 그 원칙을 last_page 와 동일하게 적용했다.
856 lines
32 KiB
PHP
856 lines
32 KiB
PHP
<?php
|
|
|
|
namespace Tests\Feature\Api\Admin;
|
|
|
|
use App\Enums\ActivityLogType;
|
|
use App\Enums\ExtensionOwnerType;
|
|
use App\Helpers\PermissionHelper;
|
|
use App\Models\ActivityLog;
|
|
use App\Models\Permission;
|
|
use App\Models\Role;
|
|
use App\Models\User;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
use Illuminate\Support\Carbon;
|
|
use Illuminate\Support\Facades\Hash;
|
|
use Tests\TestCase;
|
|
|
|
/**
|
|
* ActivityLogController 테스트
|
|
*
|
|
* 활동 로그 API 엔드포인트를 테스트합니다.
|
|
*/
|
|
class ActivityLogControllerTest extends TestCase
|
|
{
|
|
use RefreshDatabase;
|
|
|
|
private User $admin;
|
|
|
|
private string $token;
|
|
|
|
protected function setUp(): void
|
|
{
|
|
parent::setUp();
|
|
|
|
$this->clearPermissionCache();
|
|
$this->admin = $this->createAdminUser();
|
|
$this->token = $this->admin->createToken('test-token')->plainTextToken;
|
|
}
|
|
|
|
protected function tearDown(): void
|
|
{
|
|
$this->clearPermissionCache();
|
|
parent::tearDown();
|
|
}
|
|
|
|
/**
|
|
* PermissionHelper 정적 캐시 초기화
|
|
*
|
|
* @return void
|
|
*/
|
|
private function clearPermissionCache(): void
|
|
{
|
|
$reflection = new \ReflectionClass(PermissionHelper::class);
|
|
$prop = $reflection->getProperty('permissionCache');
|
|
$prop->setAccessible(true);
|
|
$prop->setValue(null, []);
|
|
}
|
|
|
|
/**
|
|
* 관리자 역할 생성 및 할당 (스코프 지정 가능)
|
|
*
|
|
* @param array $permissions 사용자에게 부여할 권한 식별자 목록
|
|
* @param string|null $scopeType 스코프 타입 (null: 전체, 'self': 본인, 'role': 소유역할)
|
|
* @return User
|
|
*/
|
|
private function createAdminUser(array $permissions = ['core.activities.read'], ?string $scopeType = null): User
|
|
{
|
|
$user = User::factory()->create([
|
|
'password' => Hash::make('password123'),
|
|
]);
|
|
|
|
// 권한 생성
|
|
$permissionIds = [];
|
|
foreach ($permissions as $permIdentifier) {
|
|
$attrs = [
|
|
'name' => json_encode(['ko' => $permIdentifier, 'en' => $permIdentifier]),
|
|
'description' => json_encode(['ko' => $permIdentifier.' 권한', 'en' => $permIdentifier.' Permission']),
|
|
'extension_type' => ExtensionOwnerType::Core,
|
|
'extension_identifier' => 'core',
|
|
'type' => 'admin',
|
|
];
|
|
|
|
// 스코프 지원 권한에 resource_route_key, owner_key 설정
|
|
if ($permIdentifier === 'core.activities.read') {
|
|
$attrs['resource_route_key'] = 'activityLog';
|
|
$attrs['owner_key'] = 'user_id';
|
|
}
|
|
|
|
$permission = Permission::firstOrCreate(
|
|
['identifier' => $permIdentifier],
|
|
$attrs
|
|
);
|
|
|
|
// firstOrCreate로 이미 존재하는 경우 owner_key가 없을 수 있으므로 업데이트
|
|
if ($permIdentifier === 'core.activities.read' && $permission->owner_key === null) {
|
|
$permission->update([
|
|
'resource_route_key' => 'activityLog',
|
|
'owner_key' => 'user_id',
|
|
]);
|
|
}
|
|
|
|
$permissionIds[] = $permission->id;
|
|
}
|
|
|
|
// 고유한 식별자로 역할 생성
|
|
$roleIdentifier = 'admin_test_'.uniqid();
|
|
$adminRole = Role::create([
|
|
'identifier' => $roleIdentifier,
|
|
'name' => json_encode(['ko' => '테스트 관리자', 'en' => 'Test Administrator']),
|
|
'description' => json_encode(['ko' => '테스트 관리자', 'en' => 'Test Administrator']),
|
|
'is_active' => true,
|
|
]);
|
|
|
|
// admin 역할 (admin 미들웨어 통과용)
|
|
$adminBaseRole = Role::firstOrCreate(
|
|
['identifier' => 'admin'],
|
|
[
|
|
'name' => json_encode(['ko' => '관리자', 'en' => 'Administrator']),
|
|
'description' => json_encode(['ko' => '시스템 관리자', 'en' => 'System Administrator']),
|
|
'extension_type' => ExtensionOwnerType::Core,
|
|
'extension_identifier' => 'core',
|
|
'is_active' => true,
|
|
]
|
|
);
|
|
|
|
// 테스트용 역할에 권한 할당 (스코프 타입 포함)
|
|
$syncData = [];
|
|
foreach ($permissionIds as $permId) {
|
|
$syncData[$permId] = ['scope_type' => $scopeType];
|
|
}
|
|
$adminRole->permissions()->sync($syncData);
|
|
|
|
// 사용자에게 admin 역할과 테스트용 역할 모두 할당
|
|
$user->roles()->attach($adminBaseRole->id, [
|
|
'assigned_at' => now(),
|
|
'assigned_by' => null,
|
|
]);
|
|
$user->roles()->attach($adminRole->id, [
|
|
'assigned_at' => now(),
|
|
'assigned_by' => null,
|
|
]);
|
|
|
|
return $user->fresh();
|
|
}
|
|
|
|
/**
|
|
* 인증 헤더와 함께 요청
|
|
*
|
|
* @return $this
|
|
*/
|
|
private function authRequest(): static
|
|
{
|
|
return $this->withHeaders([
|
|
'Authorization' => 'Bearer '.$this->token,
|
|
'Accept' => 'application/json',
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* 테스트용 활동 로그 생성
|
|
*
|
|
* @param array $overrides 오버라이드할 속성
|
|
* @return ActivityLog
|
|
*/
|
|
private function createActivityLog(array $overrides = []): ActivityLog
|
|
{
|
|
return ActivityLog::create(array_merge([
|
|
'log_type' => ActivityLogType::Admin,
|
|
'action' => 'user.update',
|
|
'description_key' => 'activity_log.description.user_update',
|
|
'description_params' => ['user_id' => 'abc-123'],
|
|
'loggable_type' => 'App\\Models\\User',
|
|
'loggable_id' => 1,
|
|
'user_id' => $this->admin->id,
|
|
'ip_address' => '127.0.0.1',
|
|
], $overrides));
|
|
}
|
|
|
|
// ========================================================================
|
|
// 인증/권한 테스트
|
|
// ========================================================================
|
|
|
|
public function test_index_returns_401_without_authentication(): void
|
|
{
|
|
$response = $this->getJson('/api/admin/activity-logs');
|
|
$response->assertStatus(401);
|
|
}
|
|
|
|
public function test_index_returns_403_without_permission(): void
|
|
{
|
|
$user = $this->createAdminUser([]);
|
|
$token = $user->createToken('test-token')->plainTextToken;
|
|
|
|
$response = $this->withHeaders([
|
|
'Authorization' => 'Bearer '.$token,
|
|
'Accept' => 'application/json',
|
|
])->getJson('/api/admin/activity-logs');
|
|
|
|
$response->assertStatus(403);
|
|
}
|
|
|
|
// ========================================================================
|
|
// index 엔드포인트 - 응답 구조
|
|
// ========================================================================
|
|
|
|
public function test_index_returns_data_and_pagination_structure(): void
|
|
{
|
|
$this->createActivityLog();
|
|
$this->createActivityLog(['action' => 'user.create']);
|
|
$this->createActivityLog(['action' => 'user.delete']);
|
|
|
|
$response = $this->authRequest()->getJson('/api/admin/activity-logs');
|
|
|
|
$response->assertStatus(200)
|
|
->assertJsonStructure([
|
|
'success',
|
|
'message',
|
|
'data' => [
|
|
'data',
|
|
'pagination' => [
|
|
'current_page',
|
|
'last_page',
|
|
'per_page',
|
|
'total',
|
|
'from',
|
|
'to',
|
|
'has_more_pages',
|
|
// 활동 로그는 상한을 건 집계라 총 건수가 잘릴 수 있다. 정확도 필드가
|
|
// 빠지면 잘린 값이 화면에서 정확한 건수로 읽힌다 (실측: 실제 88,792 건이
|
|
// "10000건" 으로 표기). 메타를 손으로 조립하면 이 필드들이 사라지므로
|
|
// 표준 메타(BaseApiCollection::paginationMeta)를 쓰는지 여기서 고정한다.
|
|
'total_relation',
|
|
'total_is_exact',
|
|
'result_cap',
|
|
],
|
|
],
|
|
]);
|
|
|
|
$this->assertTrue(
|
|
$response->json('data.pagination.total_is_exact'),
|
|
'작은 표본에서는 총 건수가 정확해야 합니다.'
|
|
);
|
|
}
|
|
|
|
public function test_index_data_items_have_correct_fields(): void
|
|
{
|
|
$this->createActivityLog();
|
|
|
|
$response = $this->authRequest()->getJson('/api/admin/activity-logs');
|
|
|
|
$response->assertStatus(200);
|
|
$data = $response->json('data.data');
|
|
$this->assertCount(1, $data);
|
|
|
|
$item = $data[0];
|
|
$this->assertArrayHasKey('id', $item);
|
|
$this->assertArrayHasKey('log_type', $item);
|
|
$this->assertArrayHasKey('log_type_label', $item);
|
|
$this->assertArrayHasKey('loggable_type', $item);
|
|
$this->assertArrayHasKey('loggable_type_display', $item);
|
|
$this->assertArrayHasKey('loggable_id', $item);
|
|
$this->assertArrayHasKey('action', $item);
|
|
$this->assertArrayHasKey('action_label', $item);
|
|
$this->assertArrayHasKey('localized_description', $item);
|
|
$this->assertArrayHasKey('actor_name', $item);
|
|
$this->assertArrayHasKey('user', $item);
|
|
$this->assertArrayHasKey('ip_address', $item);
|
|
$this->assertArrayHasKey('created_at', $item);
|
|
$this->assertArrayHasKey('changes', $item);
|
|
$this->assertArrayHasKey('has_changes', $item);
|
|
$this->assertArrayHasKey('bulk_changes', $item);
|
|
$this->assertArrayHasKey('number', $item);
|
|
}
|
|
|
|
public function test_index_includes_loggable_type_display(): void
|
|
{
|
|
$this->createActivityLog([
|
|
'loggable_type' => 'Modules\\Sirsoft\\Ecommerce\\Models\\Product',
|
|
'loggable_id' => 42,
|
|
]);
|
|
|
|
$response = $this->authRequest()->getJson('/api/admin/activity-logs');
|
|
|
|
$response->assertStatus(200);
|
|
$data = $response->json('data.data');
|
|
$this->assertEquals('Product', $data[0]['loggable_type_display']);
|
|
}
|
|
|
|
public function test_index_returns_empty_data_when_no_logs(): void
|
|
{
|
|
$response = $this->authRequest()->getJson('/api/admin/activity-logs');
|
|
|
|
$response->assertStatus(200);
|
|
$this->assertCount(0, $response->json('data.data'));
|
|
$this->assertEquals(0, $response->json('data.pagination.total'));
|
|
}
|
|
|
|
// ========================================================================
|
|
// index 엔드포인트 - 필터
|
|
// ========================================================================
|
|
|
|
public function test_index_filters_by_log_type_array(): void
|
|
{
|
|
$this->createActivityLog(['log_type' => ActivityLogType::Admin]);
|
|
$this->createActivityLog(['log_type' => ActivityLogType::User]);
|
|
$this->createActivityLog(['log_type' => ActivityLogType::System]);
|
|
|
|
$response = $this->authRequest()->getJson('/api/admin/activity-logs?log_type[]=admin&log_type[]=user');
|
|
|
|
$response->assertStatus(200);
|
|
$this->assertCount(2, $response->json('data.data'));
|
|
}
|
|
|
|
public function test_index_filters_by_single_log_type(): void
|
|
{
|
|
$this->createActivityLog(['log_type' => ActivityLogType::Admin]);
|
|
$this->createActivityLog(['log_type' => ActivityLogType::User]);
|
|
$this->createActivityLog(['log_type' => ActivityLogType::System]);
|
|
|
|
$response = $this->authRequest()->getJson('/api/admin/activity-logs?log_type[]=admin');
|
|
|
|
$response->assertStatus(200);
|
|
$this->assertCount(1, $response->json('data.data'));
|
|
}
|
|
|
|
public function test_index_filters_by_search_keyword(): void
|
|
{
|
|
$this->createActivityLog(['action' => 'user.create', 'ip_address' => '10.0.0.1']);
|
|
$this->createActivityLog(['action' => 'product.update', 'ip_address' => '192.168.1.1']);
|
|
|
|
$response = $this->authRequest()->getJson('/api/admin/activity-logs?search=product');
|
|
|
|
$response->assertStatus(200);
|
|
$this->assertCount(1, $response->json('data.data'));
|
|
}
|
|
|
|
public function test_index_search_matches_ip_address(): void
|
|
{
|
|
$this->createActivityLog(['ip_address' => '10.0.0.1']);
|
|
$this->createActivityLog(['ip_address' => '192.168.1.1']);
|
|
|
|
$response = $this->authRequest()->getJson('/api/admin/activity-logs?search=192.168');
|
|
|
|
$response->assertStatus(200);
|
|
$this->assertCount(1, $response->json('data.data'));
|
|
}
|
|
|
|
public function test_index_filters_by_search_type_action(): void
|
|
{
|
|
$this->createActivityLog(['action' => 'user.create', 'ip_address' => '10.0.0.1']);
|
|
$this->createActivityLog(['action' => 'product.update', 'ip_address' => '192.168.1.1']);
|
|
|
|
// search_type=action → action 필드에서만 검색
|
|
$response = $this->authRequest()->getJson('/api/admin/activity-logs?search=product&search_type=action');
|
|
|
|
$response->assertStatus(200);
|
|
$this->assertCount(1, $response->json('data.data'));
|
|
$this->assertEquals('product.update', $response->json('data.data.0.action'));
|
|
}
|
|
|
|
public function test_index_filters_by_search_type_ip_address(): void
|
|
{
|
|
$this->createActivityLog(['action' => 'user.create', 'ip_address' => '10.0.0.1']);
|
|
$this->createActivityLog(['action' => 'product.update', 'ip_address' => '192.168.1.1']);
|
|
|
|
// search_type=ip_address → ip_address 필드에서만 검색
|
|
$response = $this->authRequest()->getJson('/api/admin/activity-logs?search=192.168&search_type=ip_address');
|
|
|
|
$response->assertStatus(200);
|
|
$this->assertCount(1, $response->json('data.data'));
|
|
}
|
|
|
|
public function test_index_search_type_action_does_not_match_ip(): void
|
|
{
|
|
$this->createActivityLog(['action' => 'user.create', 'ip_address' => '192.168.1.1']);
|
|
|
|
// search_type=action → IP 주소로 검색해도 결과 없음
|
|
$response = $this->authRequest()->getJson('/api/admin/activity-logs?search=192.168&search_type=action');
|
|
|
|
$response->assertStatus(200);
|
|
$this->assertCount(0, $response->json('data.data'));
|
|
}
|
|
|
|
public function test_index_filters_by_created_by_uuid(): void
|
|
{
|
|
$user1 = User::factory()->create(['name' => '홍길동']);
|
|
$user2 = User::factory()->create(['name' => '김철수']);
|
|
|
|
$this->createActivityLog(['user_id' => $user1->id]);
|
|
$this->createActivityLog(['user_id' => $user2->id]);
|
|
|
|
$response = $this->authRequest()->getJson('/api/admin/activity-logs?created_by='.$user1->uuid);
|
|
|
|
$response->assertStatus(200);
|
|
$this->assertCount(1, $response->json('data.data'));
|
|
$this->assertEquals('홍길동', $response->json('data.data.0.actor_name'));
|
|
}
|
|
|
|
public function test_index_bilingual_search_finds_by_action_translation(): void
|
|
{
|
|
// action 'user.create' → 번역 키 'activity_log.action.user.create' 또는 'activity_log.action.create'
|
|
$this->createActivityLog(['action' => 'user.create']);
|
|
$this->createActivityLog(['action' => 'product.update']);
|
|
|
|
// 영어 원문으로 검색
|
|
$response = $this->authRequest()->getJson('/api/admin/activity-logs?search=user.create&search_type=action');
|
|
|
|
$response->assertStatus(200);
|
|
$this->assertCount(1, $response->json('data.data'));
|
|
$this->assertEquals('user.create', $response->json('data.data.0.action'));
|
|
}
|
|
|
|
public function test_index_bilingual_search_finds_by_description_key(): void
|
|
{
|
|
$this->createActivityLog([
|
|
'description_key' => 'activity_log.description.user_update',
|
|
]);
|
|
$this->createActivityLog([
|
|
'description_key' => 'activity_log.description.product_create',
|
|
]);
|
|
|
|
// description_key 원문으로 검색
|
|
$response = $this->authRequest()->getJson('/api/admin/activity-logs?search=user_update&search_type=description');
|
|
|
|
$response->assertStatus(200);
|
|
$this->assertCount(1, $response->json('data.data'));
|
|
}
|
|
|
|
public function test_index_includes_has_changes_field(): void
|
|
{
|
|
// changes가 있는 로그 (오래된 것)
|
|
$this->createActivityLog([
|
|
'changes' => [
|
|
['field' => 'name', 'old' => '기존', 'new' => '변경'],
|
|
],
|
|
'created_at' => Carbon::parse('2026-01-01 10:00:00'),
|
|
]);
|
|
|
|
// changes가 없는 로그 (최신)
|
|
$this->createActivityLog([
|
|
'changes' => null,
|
|
'created_at' => Carbon::parse('2026-01-02 10:00:00'),
|
|
]);
|
|
|
|
$response = $this->authRequest()->getJson('/api/admin/activity-logs');
|
|
|
|
$response->assertStatus(200);
|
|
$data = $response->json('data.data');
|
|
$this->assertCount(2, $data);
|
|
|
|
// 최신순 정렬이므로 changes=null이 먼저
|
|
$this->assertFalse($data[0]['has_changes']);
|
|
$this->assertTrue($data[1]['has_changes']);
|
|
}
|
|
|
|
public function test_index_filters_by_date_range(): void
|
|
{
|
|
$this->createActivityLog(['created_at' => Carbon::parse('2026-01-15')]);
|
|
$this->createActivityLog(['created_at' => Carbon::parse('2026-02-15')]);
|
|
$this->createActivityLog(['created_at' => Carbon::parse('2026-03-15')]);
|
|
|
|
$response = $this->authRequest()->getJson('/api/admin/activity-logs?date_from=2026-02-01&date_to=2026-02-28');
|
|
|
|
$response->assertStatus(200);
|
|
$this->assertCount(1, $response->json('data.data'));
|
|
}
|
|
|
|
public function test_index_filters_by_action(): void
|
|
{
|
|
$this->createActivityLog(['action' => 'user.create']);
|
|
$this->createActivityLog(['action' => 'user.update']);
|
|
$this->createActivityLog(['action' => 'user.delete']);
|
|
|
|
$response = $this->authRequest()->getJson('/api/admin/activity-logs?action=user.create');
|
|
|
|
$response->assertStatus(200);
|
|
$this->assertCount(1, $response->json('data.data'));
|
|
}
|
|
|
|
// ========================================================================
|
|
// index 엔드포인트 - 페이지네이션
|
|
// ========================================================================
|
|
|
|
public function test_index_respects_per_page_parameter(): void
|
|
{
|
|
for ($i = 0; $i < 5; $i++) {
|
|
$this->createActivityLog(['action' => "action_{$i}"]);
|
|
}
|
|
|
|
$response = $this->authRequest()->getJson('/api/admin/activity-logs?per_page=2');
|
|
|
|
$response->assertStatus(200);
|
|
$this->assertCount(2, $response->json('data.data'));
|
|
$this->assertEquals(5, $response->json('data.pagination.total'));
|
|
$this->assertEquals(3, $response->json('data.pagination.last_page'));
|
|
}
|
|
|
|
public function test_index_returns_row_numbers(): void
|
|
{
|
|
for ($i = 0; $i < 3; $i++) {
|
|
$this->createActivityLog(['action' => "action_{$i}"]);
|
|
}
|
|
|
|
$response = $this->authRequest()->getJson('/api/admin/activity-logs');
|
|
|
|
$response->assertStatus(200);
|
|
$data = $response->json('data.data');
|
|
|
|
// 내림차순 기본 정렬 → 번호는 total부터 역순
|
|
$this->assertEquals(3, $data[0]['number']);
|
|
$this->assertEquals(2, $data[1]['number']);
|
|
$this->assertEquals(1, $data[2]['number']);
|
|
}
|
|
|
|
// ========================================================================
|
|
// index 엔드포인트 - 정렬
|
|
// ========================================================================
|
|
|
|
public function test_index_sorts_by_created_at_desc_by_default(): void
|
|
{
|
|
$this->createActivityLog(['action' => 'old', 'created_at' => Carbon::parse('2026-01-01')]);
|
|
$this->createActivityLog(['action' => 'new', 'created_at' => Carbon::parse('2026-03-01')]);
|
|
|
|
$response = $this->authRequest()->getJson('/api/admin/activity-logs');
|
|
|
|
$response->assertStatus(200);
|
|
$data = $response->json('data.data');
|
|
$this->assertEquals('new', $data[0]['action']);
|
|
$this->assertEquals('old', $data[1]['action']);
|
|
}
|
|
|
|
public function test_index_sorts_ascending_when_requested(): void
|
|
{
|
|
$this->createActivityLog(['action' => 'old', 'created_at' => Carbon::parse('2026-01-01')]);
|
|
$this->createActivityLog(['action' => 'new', 'created_at' => Carbon::parse('2026-03-01')]);
|
|
|
|
$response = $this->authRequest()->getJson('/api/admin/activity-logs?sort_order=asc');
|
|
|
|
$response->assertStatus(200);
|
|
$data = $response->json('data.data');
|
|
$this->assertEquals('old', $data[0]['action']);
|
|
$this->assertEquals('new', $data[1]['action']);
|
|
}
|
|
|
|
// ========================================================================
|
|
// 비활성화 모듈 로그 안전 처리
|
|
// ========================================================================
|
|
|
|
public function test_index_handles_deactivated_module_loggable_type_safely(): void
|
|
{
|
|
// 비활성화된 모듈의 FQCN — 클래스 미존재
|
|
$this->createActivityLog([
|
|
'loggable_type' => 'Modules\\Nonexistent\\Models\\SomeModel',
|
|
'loggable_id' => 999,
|
|
]);
|
|
|
|
$response = $this->authRequest()->getJson('/api/admin/activity-logs');
|
|
|
|
// loggable 관계를 로드하지 않으므로 오류 없이 정상 응답
|
|
$response->assertStatus(200);
|
|
$data = $response->json('data.data');
|
|
$this->assertCount(1, $data);
|
|
$this->assertEquals('SomeModel', $data[0]['loggable_type_display']);
|
|
}
|
|
|
|
// ========================================================================
|
|
// 유효성 검증 실패
|
|
// ========================================================================
|
|
|
|
public function test_index_rejects_invalid_log_type(): void
|
|
{
|
|
$response = $this->authRequest()->getJson('/api/admin/activity-logs?log_type[]=invalid');
|
|
|
|
$response->assertStatus(422);
|
|
}
|
|
|
|
public function test_index_rejects_invalid_date_range(): void
|
|
{
|
|
$response = $this->authRequest()->getJson('/api/admin/activity-logs?date_from=2026-03-28&date_to=2026-01-01');
|
|
|
|
$response->assertStatus(422);
|
|
}
|
|
|
|
public function test_index_rejects_per_page_exceeding_max(): void
|
|
{
|
|
$response = $this->authRequest()->getJson('/api/admin/activity-logs?per_page=101');
|
|
|
|
$response->assertStatus(422);
|
|
}
|
|
|
|
// ========================================================================
|
|
// index 엔드포인트 - abilities
|
|
// ========================================================================
|
|
|
|
public function test_index_includes_abilities_with_delete_permission(): void
|
|
{
|
|
// 삭제 권한도 가진 관리자
|
|
$adminWithDelete = $this->createAdminUser(['core.activities.read', 'core.activities.delete']);
|
|
$token = $adminWithDelete->createToken('test-token')->plainTextToken;
|
|
|
|
$this->createActivityLog(['user_id' => $adminWithDelete->id]);
|
|
|
|
$response = $this->withHeaders([
|
|
'Authorization' => 'Bearer '.$token,
|
|
'Accept' => 'application/json',
|
|
])->getJson('/api/admin/activity-logs');
|
|
|
|
$response->assertStatus(200);
|
|
$data = $response->json('data.data');
|
|
$this->assertTrue($data[0]['abilities']['can_delete']);
|
|
}
|
|
|
|
public function test_index_abilities_can_delete_false_without_permission(): void
|
|
{
|
|
$this->createActivityLog();
|
|
|
|
$response = $this->authRequest()->getJson('/api/admin/activity-logs');
|
|
|
|
$response->assertStatus(200);
|
|
$data = $response->json('data.data');
|
|
$this->assertFalse($data[0]['abilities']['can_delete']);
|
|
}
|
|
|
|
// ========================================================================
|
|
// index 엔드포인트 - collection-level abilities
|
|
// ========================================================================
|
|
|
|
public function test_index_includes_collection_level_abilities_with_delete_permission(): void
|
|
{
|
|
$adminWithDelete = $this->createAdminUser(['core.activities.read', 'core.activities.delete']);
|
|
$token = $adminWithDelete->createToken('test-token')->plainTextToken;
|
|
|
|
$this->createActivityLog(['user_id' => $adminWithDelete->id]);
|
|
|
|
$response = $this->withHeaders([
|
|
'Authorization' => 'Bearer '.$token,
|
|
'Accept' => 'application/json',
|
|
])->getJson('/api/admin/activity-logs');
|
|
|
|
$response->assertStatus(200);
|
|
$this->assertTrue($response->json('data.abilities.can_delete'));
|
|
}
|
|
|
|
public function test_index_collection_level_abilities_can_delete_false_without_permission(): void
|
|
{
|
|
$this->createActivityLog();
|
|
|
|
$response = $this->authRequest()->getJson('/api/admin/activity-logs');
|
|
|
|
$response->assertStatus(200);
|
|
$this->assertFalse($response->json('data.abilities.can_delete'));
|
|
}
|
|
|
|
// ========================================================================
|
|
// destroy 엔드포인트
|
|
// ========================================================================
|
|
|
|
public function test_destroy_returns_401_without_authentication(): void
|
|
{
|
|
$log = $this->createActivityLog();
|
|
|
|
$response = $this->deleteJson("/api/admin/activity-logs/{$log->id}");
|
|
$response->assertStatus(401);
|
|
}
|
|
|
|
public function test_destroy_returns_403_without_delete_permission(): void
|
|
{
|
|
$log = $this->createActivityLog();
|
|
|
|
$response = $this->authRequest()->deleteJson("/api/admin/activity-logs/{$log->id}");
|
|
$response->assertStatus(403);
|
|
}
|
|
|
|
public function test_destroy_deletes_activity_log_successfully(): void
|
|
{
|
|
$adminWithDelete = $this->createAdminUser(['core.activities.read', 'core.activities.delete']);
|
|
$token = $adminWithDelete->createToken('test-token')->plainTextToken;
|
|
|
|
$log = $this->createActivityLog();
|
|
|
|
$response = $this->withHeaders([
|
|
'Authorization' => 'Bearer '.$token,
|
|
'Accept' => 'application/json',
|
|
])->deleteJson("/api/admin/activity-logs/{$log->id}");
|
|
|
|
$response->assertStatus(200)
|
|
->assertJsonPath('success', true);
|
|
|
|
$this->assertDatabaseMissing('activity_logs', ['id' => $log->id]);
|
|
}
|
|
|
|
public function test_destroy_returns_404_for_nonexistent_log(): void
|
|
{
|
|
$adminWithDelete = $this->createAdminUser(['core.activities.read', 'core.activities.delete']);
|
|
$token = $adminWithDelete->createToken('test-token')->plainTextToken;
|
|
|
|
$response = $this->withHeaders([
|
|
'Authorization' => 'Bearer '.$token,
|
|
'Accept' => 'application/json',
|
|
])->deleteJson('/api/admin/activity-logs/99999');
|
|
|
|
$response->assertStatus(404);
|
|
}
|
|
|
|
// ========================================================================
|
|
// bulkDestroy 엔드포인트
|
|
// ========================================================================
|
|
|
|
public function test_bulk_destroy_returns_401_without_authentication(): void
|
|
{
|
|
$response = $this->postJson('/api/admin/activity-logs/bulk-delete', ['ids' => [1]]);
|
|
$response->assertStatus(401);
|
|
}
|
|
|
|
public function test_bulk_destroy_returns_403_without_delete_permission(): void
|
|
{
|
|
$log = $this->createActivityLog();
|
|
|
|
$response = $this->authRequest()->postJson('/api/admin/activity-logs/bulk-delete', ['ids' => [$log->id]]);
|
|
$response->assertStatus(403);
|
|
}
|
|
|
|
public function test_bulk_destroy_deletes_multiple_logs_successfully(): void
|
|
{
|
|
$adminWithDelete = $this->createAdminUser(['core.activities.read', 'core.activities.delete']);
|
|
$token = $adminWithDelete->createToken('test-token')->plainTextToken;
|
|
|
|
$log1 = $this->createActivityLog(['action' => 'action1']);
|
|
$log2 = $this->createActivityLog(['action' => 'action2']);
|
|
$log3 = $this->createActivityLog(['action' => 'action3']);
|
|
|
|
$response = $this->withHeaders([
|
|
'Authorization' => 'Bearer '.$token,
|
|
'Accept' => 'application/json',
|
|
])->postJson('/api/admin/activity-logs/bulk-delete', [
|
|
'ids' => [$log1->id, $log2->id],
|
|
]);
|
|
|
|
$response->assertStatus(200)
|
|
->assertJsonPath('success', true)
|
|
->assertJsonPath('data.deleted_count', 2);
|
|
|
|
$this->assertDatabaseMissing('activity_logs', ['id' => $log1->id]);
|
|
$this->assertDatabaseMissing('activity_logs', ['id' => $log2->id]);
|
|
$this->assertDatabaseHas('activity_logs', ['id' => $log3->id]);
|
|
}
|
|
|
|
public function test_bulk_destroy_validates_ids_required(): void
|
|
{
|
|
$adminWithDelete = $this->createAdminUser(['core.activities.read', 'core.activities.delete']);
|
|
$token = $adminWithDelete->createToken('test-token')->plainTextToken;
|
|
|
|
$response = $this->withHeaders([
|
|
'Authorization' => 'Bearer '.$token,
|
|
'Accept' => 'application/json',
|
|
])->postJson('/api/admin/activity-logs/bulk-delete', []);
|
|
|
|
$response->assertStatus(422);
|
|
}
|
|
|
|
public function test_bulk_destroy_validates_ids_exist(): void
|
|
{
|
|
$adminWithDelete = $this->createAdminUser(['core.activities.read', 'core.activities.delete']);
|
|
$token = $adminWithDelete->createToken('test-token')->plainTextToken;
|
|
|
|
$response = $this->withHeaders([
|
|
'Authorization' => 'Bearer '.$token,
|
|
'Accept' => 'application/json',
|
|
])->postJson('/api/admin/activity-logs/bulk-delete', [
|
|
'ids' => [99999],
|
|
]);
|
|
|
|
$response->assertStatus(422);
|
|
}
|
|
|
|
// ========================================================================
|
|
// index 엔드포인트 - 스코프 기반 권한
|
|
// ========================================================================
|
|
|
|
public function test_index_scope_self_returns_only_own_logs(): void
|
|
{
|
|
$selfScopeUser = $this->createAdminUser(['core.activities.read'], 'self');
|
|
$token = $selfScopeUser->createToken('test-token')->plainTextToken;
|
|
|
|
$otherUser = User::factory()->create();
|
|
|
|
// 본인 로그
|
|
$this->createActivityLog(['user_id' => $selfScopeUser->id, 'action' => 'own.action']);
|
|
// 타인 로그
|
|
$this->createActivityLog(['user_id' => $otherUser->id, 'action' => 'other.action']);
|
|
|
|
$response = $this->withHeaders([
|
|
'Authorization' => 'Bearer '.$token,
|
|
'Accept' => 'application/json',
|
|
])->getJson('/api/admin/activity-logs');
|
|
|
|
$response->assertStatus(200);
|
|
$data = $response->json('data.data');
|
|
$this->assertCount(1, $data);
|
|
$this->assertEquals('own.action', $data[0]['action']);
|
|
}
|
|
|
|
public function test_index_scope_null_returns_all_logs(): void
|
|
{
|
|
// 기본 createAdminUser는 scopeType=null (전체 접근)
|
|
$otherUser = User::factory()->create();
|
|
|
|
$this->createActivityLog(['user_id' => $this->admin->id, 'action' => 'own.action']);
|
|
$this->createActivityLog(['user_id' => $otherUser->id, 'action' => 'other.action']);
|
|
|
|
$response = $this->authRequest()->getJson('/api/admin/activity-logs');
|
|
|
|
$response->assertStatus(200);
|
|
$this->assertCount(2, $response->json('data.data'));
|
|
}
|
|
|
|
public function test_index_scope_role_returns_shared_role_logs(): void
|
|
{
|
|
$roleScopeUser = $this->createAdminUser(['core.activities.read'], 'role');
|
|
$token = $roleScopeUser->createToken('test-token')->plainTextToken;
|
|
|
|
// 동일 역할을 공유하는 사용자 생성
|
|
$sharedRoleUser = User::factory()->create();
|
|
$roleScopeUserRoles = $roleScopeUser->roles()->where('identifier', '!=', 'admin')->pluck('roles.id');
|
|
foreach ($roleScopeUserRoles as $roleId) {
|
|
$sharedRoleUser->roles()->attach($roleId, [
|
|
'assigned_at' => now(),
|
|
'assigned_by' => null,
|
|
]);
|
|
}
|
|
|
|
// 다른 역할만 가진 사용자
|
|
$unrelatedUser = User::factory()->create();
|
|
|
|
// 본인 로그
|
|
$this->createActivityLog(['user_id' => $roleScopeUser->id, 'action' => 'own.action']);
|
|
// 동일 역할 사용자 로그
|
|
$this->createActivityLog(['user_id' => $sharedRoleUser->id, 'action' => 'shared.action']);
|
|
// 관련 없는 사용자 로그
|
|
$this->createActivityLog(['user_id' => $unrelatedUser->id, 'action' => 'unrelated.action']);
|
|
|
|
$response = $this->withHeaders([
|
|
'Authorization' => 'Bearer '.$token,
|
|
'Accept' => 'application/json',
|
|
])->getJson('/api/admin/activity-logs');
|
|
|
|
$response->assertStatus(200);
|
|
$data = $response->json('data.data');
|
|
$this->assertCount(2, $data);
|
|
|
|
$actions = collect($data)->pluck('action')->toArray();
|
|
$this->assertContains('own.action', $actions);
|
|
$this->assertContains('shared.action', $actions);
|
|
$this->assertNotContains('unrelated.action', $actions);
|
|
}
|
|
}
|