Files
Gnuboard7/tests/Feature/Api/Admin/NotificationDefinitionListPayloadTest.php
T
HeuJung b5c0ec1178 fix(core,ecommerce,board,page,admin_basic,basic): 목록 응답의 하위 컬렉션 전량 직렬화 해소
관리자 상품목록을 한 페이지 여는 것만으로 그 페이지 모든 상품의 옵션이 응답에 실렸다.
같은 패턴을 저장소 전역에서 찾아 14개 목록 엔드포인트를 함께 정리했다.

근본 원인은 둘이다. Resource 가 whenLoaded 로 방어하는데 Repository 가 목록 쿼리에서
관계를 무조건 로드해 가드가 항상 참이 되는 가짜 가드, 그리고 toListArray 경량 표현을
정의해 두고도 컬렉션이 toArray 를 부르는 목록/상세 미분리다. 둘 다 응답만 보면
정상이라 오류도 경고도 없이 페이로드만 불어난다.

목록은 화면이 실제로 그리는 것만 싣는다. 개수·합계는 PHP 컬렉션 연산이 아니라 DB
집계로, 대표 1건이 필요한 곳은 관계 자체를 oldestOfMany 로 좁힌다. eager load 의
limit(1) 은 부모별이 아니라 배치 쿼리 전체에 걸려 첫 행만 값을 갖게 되므로 쓸 수 없다.

뺀 값에는 대체 경로를 먼저 만들었다. 상품 옵션은 행을 펼칠 때 배치로 불러오고(상품 수와
무관하게 쿼리 상수), 종전 동작이 필요한 호출자를 위해 ?with_options=1 등 opt-in 을 남겼다.
배송정책 국가설정과 리뷰 첨부 이미지는 소비처를 실측한 결과 화면이 실제로 그리고 있어
제거하지 않았다 — 그 소비 사실을 회귀 테스트로 고정했다.

재발 방지로 정적 검사 룰 4종과 규정 문서 항목을 함께 넣었다.
2026-08-05 21:20:05 +09:00

293 lines
10 KiB
PHP

<?php
namespace Tests\Feature\Api\Admin;
use App\Models\NotificationDefinition;
use App\Models\NotificationTemplate;
use App\Models\Permission;
use App\Models\Role;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Tests\TestCase;
/**
* 관리자 알림 정의 목록 페이로드 프루닝 (#518 / 공개 #76).
*
* 알림 정의 화면은 한 번에 **한 채널** 만 그리는데, 종전에는 정의마다 모든 채널의 제목·본문이
* 통째로 목록 응답에 실렸다. 목록에 실을 템플릿을 `template_channel` 로 좁히고, 채널을 가리지
* 않는 판정("커스터마이즈된 템플릿이 있는가")은 집계로 대체한다.
*
* `channel` 필터와 혼동하지 않는다 — `channel` 은 정의 행 자체를 거르고(그 채널을 지원하지
* 않는 정의가 목록에서 사라진다), `template_channel` 은 행을 남긴 채 템플릿만 좁힌다.
*
*/
class NotificationDefinitionListPayloadTest extends TestCase
{
use RefreshDatabase;
private User $admin;
private string $token;
protected function setUp(): void
{
parent::setUp();
$this->admin = $this->createAdminWithPermissions();
$this->token = $this->admin->createToken('test-token')->plainTextToken;
}
/**
* 인증 헤더가 붙은 요청 빌더를 반환합니다.
*
* @return static
*/
private function authRequest(): static
{
return $this->withHeaders([
'Authorization' => 'Bearer '.$this->token,
'Accept' => 'application/json',
]);
}
/**
* core.settings 권한을 가진 관리자를 만듭니다.
*
* @return User 생성된 관리자
*/
private function createAdminWithPermissions(): User
{
$user = User::factory()->create();
$role = Role::create([
'name' => ['ko' => '최고관리자'],
'identifier' => 'super_admin',
'is_admin' => true,
]);
foreach (['core.settings.read', 'core.settings.update'] as $identifier) {
$perm = Permission::firstOrCreate(
['identifier' => $identifier],
['name' => ['ko' => $identifier], 'type' => 'admin', 'order' => 1]
);
$role->permissions()->attach($perm->id);
}
$user->roles()->attach($role->id);
return $user;
}
/**
* 여러 채널 템플릿을 가진 알림 정의를 만듭니다.
*
* @param string $type 정의 타입
* @param array<int, string> $channels 만들 채널 목록
* @param array<int, string> $customizedChannels is_default=false 로 만들 채널 목록
* @return NotificationDefinition 생성된 정의
*/
private function makeDefinition(string $type, array $channels, array $customizedChannels = []): NotificationDefinition
{
$definition = NotificationDefinition::create([
'type' => $type,
'hook_prefix' => 'core.test',
'extension_type' => 'core',
'extension_identifier' => 'core',
'name' => ['ko' => $type],
'channels' => $channels,
'hooks' => [],
]);
foreach ($channels as $channel) {
NotificationTemplate::create([
'definition_id' => $definition->id,
'channel' => $channel,
'subject' => ['ko' => "제목-{$type}-{$channel}"],
'body' => ['ko' => str_repeat("본문-{$channel} ", 50)],
'is_default' => ! in_array($channel, $customizedChannels, true),
]);
}
return $definition;
}
/**
* template_channel 을 주면 그 채널의 템플릿만 실린다.
*
* @return void
*
* @scenario resource=notification_definition,endpoint=list,observation=response_payload
* @effects notification_list_carries_only_requested_channel_template
*/
public function test_list_carries_only_the_requested_channel_template(): void
{
$this->makeDefinition('welcome', ['mail', 'sms', 'push']);
$this->makeDefinition('reset', ['mail', 'sms']);
$rows = $this->authRequest()
->getJson('/api/admin/notification-definitions?template_channel=mail')
->assertStatus(200)
->json('data.data');
$this->assertCount(2, $rows);
foreach ($rows as $row) {
$this->assertCount(
1,
$row['templates'],
'요청한 채널 외의 제목·본문이 실리면 한 페이지를 여는 것만으로 전 채널 본문이 전송된다'
);
$this->assertSame('mail', $row['templates'][0]['channel']);
}
}
/**
* template_channel 은 정의 행을 거르지 않는다 (channel 필터와의 차이).
*
* 그 채널 템플릿이 없는 정의도 행은 남아 "채널 미설정" 안내를 띄울 수 있어야 한다.
*
* @return void
*
* @scenario resource=notification_definition,endpoint=list,observation=response_payload
* @effects notification_list_carries_only_requested_channel_template
*/
public function test_template_channel_does_not_filter_out_rows(): void
{
$this->makeDefinition('mail_only', ['mail']);
$this->makeDefinition('sms_only', ['sms']);
$rows = $this->authRequest()
->getJson('/api/admin/notification-definitions?template_channel=mail')
->json('data.data');
$this->assertCount(2, $rows, '행 자체는 남아야 한다 — 거르는 것은 channel 파라미터의 역할이다');
$byType = collect($rows)->keyBy('type');
$this->assertCount(1, $byType['mail_only']['templates']);
$this->assertCount(0, $byType['sms_only']['templates'], '요청 채널 템플릿이 없으면 빈 배열');
}
/**
* template_channel 이 없으면 템플릿 본문을 아예 싣지 않는다.
*
* 채널을 지정하지 않은 호출자에게 전 채널 본문을 안기면, 한 페이지를 여는 것만으로
* 정의 수 × 채널 × 로케일만큼의 제목·본문이 응답에 실린다. 전 채널이 필요하면 단건
* 조회가 제공한다.
*
* @return void
*
* @scenario resource=notification_definition,endpoint=list,observation=response_payload
* @effects notification_list_without_channel_omits_templates
*/
public function test_list_without_template_channel_omits_templates(): void
{
$this->makeDefinition('welcome', ['mail', 'sms', 'push']);
$row = $this->authRequest()
->getJson('/api/admin/notification-definitions')
->json('data.data.0');
$this->assertArrayNotHasKey(
'templates',
$row,
'채널을 지정하지 않으면 템플릿 본문을 싣지 않는다 — 빈 배열도 아니고 키 자체가 없다'
);
// 되돌리기 버튼 판정은 템플릿 배열 없이도 성립해야 한다
$this->assertArrayHasKey('has_customized_templates', $row);
}
/**
* 커스터마이즈 여부는 요청 채널과 무관하게 정확하다.
*
* 되돌리기 버튼 노출 조건이므로, 다른 채널만 수정한 경우에도 참이어야 한다.
* 템플릿 배열만 훑으면 이 경우를 놓친다.
*
* @return void
*
* @scenario resource=notification_definition,endpoint=list,observation=response_payload
* @effects notification_customized_flag_spans_every_channel
*/
public function test_customized_flag_spans_every_channel_not_just_the_requested_one(): void
{
// sms 만 커스터마이즈 — mail 채널로 조회해도 "수정됨" 이어야 한다
$this->makeDefinition('partly_customized', ['mail', 'sms'], ['sms']);
$this->makeDefinition('untouched', ['mail', 'sms']);
$rows = $this->authRequest()
->getJson('/api/admin/notification-definitions?template_channel=mail')
->json('data.data');
$byType = collect($rows)->keyBy('type');
$this->assertTrue(
$byType['partly_customized']['has_customized_templates'],
'다른 채널만 수정된 경우를 놓치면 되돌리기 버튼이 사라진다'
);
$this->assertFalse($byType['untouched']['has_customized_templates']);
}
/**
* 템플릿 조회 쿼리 수가 정의 수에 비례하지 않는다 (N+1 부재).
*
* @return void
*
* @scenario resource=notification_definition,endpoint=list,observation=response_payload
* @effects query_count_does_not_scale_with_row_count
*/
public function test_template_query_count_does_not_grow_with_definition_count(): void
{
$this->makeDefinition('first', ['mail', 'sms']);
// 권한/설정 캐시를 채워 측정에서 제외
$this->authRequest()->getJson('/api/admin/notification-definitions?template_channel=mail');
$countTemplateQueries = function (): int {
$count = 0;
DB::listen(function ($query) use (&$count) {
if (str_contains($query->sql, 'notification_templates')) {
$count++;
}
});
$this->authRequest()->getJson('/api/admin/notification-definitions?template_channel=mail');
return $count;
};
$withOne = $countTemplateQueries();
foreach (['b', 'c', 'd', 'e', 'f'] as $type) {
$this->makeDefinition($type, ['mail', 'sms']);
}
$withSix = $countTemplateQueries();
$this->assertSame(
$withOne,
$withSix,
"정의 1건일 때 {$withOne}회, 6건일 때 {$withSix}회 — 정의 수에 비례하면 N+1 이다"
);
}
/**
* 단건 조회는 전체 템플릿을 그대로 공급한다 (대체 경로 보존).
*
* @return void
*
* @scenario resource=notification_definition,endpoint=detail,observation=response_payload
* @effects notification_detail_returns_every_channel_template, detail_still_returns_full_payload
*/
public function test_detail_still_returns_every_channel_template(): void
{
$definition = $this->makeDefinition('welcome', ['mail', 'sms', 'push']);
$row = $this->authRequest()
->getJson("/api/admin/notification-definitions/{$definition->id}")
->assertStatus(200)
->json('data');
$this->assertCount(3, $row['templates']);
}
}