Files
Gnuboard7/tests/Feature/Api/Admin/Identity/AdminIdentityMessageDefinitionListPayloadTest.php
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

320 lines
12 KiB
PHP

<?php
namespace Tests\Feature\Api\Admin\Identity;
use App\Models\IdentityMessageDefinition;
use App\Models\IdentityMessageTemplate;
use App\Models\Role;
use App\Models\User;
use Database\Seeders\RolePermissionSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Tests\TestCase;
/**
* 관리자 — IDV 메시지 정의 목록 페이로드 프루닝 (#518 / 공개 #76).
*
* 정의 목록 화면이 그리는 것은 대표 템플릿 1건의 제목/본문뿐인데, 종전에는 정의마다 모든 채널의
* 템플릿 본문이 통째로 응답에 실렸다. 목록은 대표 1건만 싣고 전체는 단건 조회가 공급한다.
*
*/
class AdminIdentityMessageDefinitionListPayloadTest extends TestCase
{
use RefreshDatabase;
private User $admin;
private string $token;
protected function setUp(): void
{
parent::setUp();
$this->seed(RolePermissionSeeder::class);
$this->admin = User::factory()->create(['is_super' => true]);
$adminRole = Role::where('identifier', 'admin')->first();
if ($adminRole) {
$this->admin->roles()->attach($adminRole->id, [
'assigned_at' => now(),
'assigned_by' => null,
]);
}
$this->admin = $this->admin->fresh();
$this->token = $this->admin->createToken('test-token')->plainTextToken;
}
/**
* 인증 헤더가 붙은 요청 빌더를 반환합니다.
*
* @return static
*/
private function authRequest(): static
{
return $this->withHeaders([
'Authorization' => 'Bearer '.$this->token,
'Accept' => 'application/json',
]);
}
/**
* 채널 여러 개를 가진 메시지 정의를 만듭니다.
*
* @param string $key 정의 식별 키 (scope_value)
* @param array<int, string> $channels 만들 채널 목록 (첫 번째가 대표)
* @return IdentityMessageDefinition 생성된 정의
*/
private function makeDefinitionWithChannels(string $key, array $channels): IdentityMessageDefinition
{
$definition = IdentityMessageDefinition::create([
'provider_id' => 'test-provider',
'scope_type' => 'purpose',
'scope_value' => $key,
'name' => ['ko' => '정의 '.$key],
'description' => ['ko' => '설명'],
'channels' => $channels,
'variables' => [],
'extension_type' => 'core',
'extension_identifier' => 'core',
'is_active' => true,
'is_default' => true,
]);
foreach ($channels as $channel) {
IdentityMessageTemplate::create([
'definition_id' => $definition->id,
'channel' => $channel,
'subject' => ['ko' => "제목-{$key}-{$channel}"],
'body' => ['ko' => str_repeat("본문-{$channel} ", 50)],
'is_active' => true,
'is_default' => true,
]);
}
return $definition;
}
/**
* 목록은 정의마다 대표 템플릿 1건만 싣는다.
*
* @return void
*
* @scenario resource=identity_message_definition,endpoint=list,observation=response_payload
* @effects identity_definition_list_carries_representative_template_only
*/
public function test_definition_list_carries_only_the_representative_template(): void
{
$this->makeDefinitionWithChannels('first', ['mail', 'sms', 'push']);
$this->makeDefinitionWithChannels('second', ['mail', 'sms']);
$response = $this->authRequest()->getJson('/api/admin/identity/messages/definitions');
$response->assertStatus(200);
$rows = $response->json('data.data');
$this->assertCount(2, $rows);
foreach ($rows as $row) {
$this->assertArrayHasKey('templates', $row, '화면 계약은 templates 배열이다 — 키 자체는 유지해야 한다');
$this->assertCount(
1,
$row['templates'],
'목록에 채널별 템플릿이 전부 실리면 한 페이지를 여는 것만으로 모든 채널 본문이 전송된다'
);
}
}
/**
* 대표 1건만 싣는 대신, 템플릿이 더 있다는 사실은 집계로 알린다.
*
* @return void
*
* @scenario resource=identity_message_definition,endpoint=list,observation=response_payload
* @effects identity_definition_list_reports_total_template_count
*/
public function test_definition_list_reports_total_template_count(): void
{
$this->makeDefinitionWithChannels('first', ['mail', 'sms', 'push']);
$row = $this->authRequest()
->getJson('/api/admin/identity/messages/definitions')
->json('data.data.0');
$this->assertSame(3, $row['templates_count'], '배열 길이로는 알 수 없으므로 집계가 필요하다');
$this->assertCount(1, $row['templates']);
}
/**
* 단건 조회는 전체 템플릿을 그대로 공급한다 (대체 경로 보존 — 기능 축소가 아니다).
*
* @return void
*
* @scenario resource=identity_message_definition,endpoint=detail,observation=response_payload
* @effects identity_definition_detail_returns_every_template, detail_still_returns_full_payload
*/
public function test_definition_detail_still_returns_every_template(): void
{
$definition = $this->makeDefinitionWithChannels('first', ['mail', 'sms', 'push']);
$row = $this->authRequest()
->getJson("/api/admin/identity/messages/definitions/{$definition->id}")
->assertStatus(200)
->json('data');
$this->assertCount(3, $row['templates']);
$this->assertSame(
['mail', 'push', 'sms'],
collect($row['templates'])->pluck('channel')->sort()->values()->all()
);
}
/**
* 목록의 대표 템플릿은 가장 먼저 등록된 것이다 (정의별 1건 보장).
*
* eager load 의 limit(1) 은 부모별이 아니라 배치 전체에 걸리므로, 잘못 구현하면 첫 정의만
* 템플릿을 받고 나머지는 빈 배열이 된다.
*
* @return void
*
* @scenario resource=identity_message_definition,endpoint=list,observation=response_payload
* @effects identity_definition_every_row_gets_its_own_representative
*/
public function test_every_definition_gets_its_own_representative_template(): void
{
$this->makeDefinitionWithChannels('first', ['mail', 'sms']);
$this->makeDefinitionWithChannels('second', ['sms', 'mail']);
$this->makeDefinitionWithChannels('third', ['push', 'mail']);
$rows = $this->authRequest()
->getJson('/api/admin/identity/messages/definitions')
->json('data.data');
foreach ($rows as $row) {
$this->assertCount(
1,
$row['templates'],
'정의마다 대표 1건씩 — 배치 전체에 limit 이 걸리면 첫 행만 채워진다'
);
}
// 대표는 정의별 "가장 먼저 등록된" 채널이어야 한다 (생성 순서 = 위 배열 순서)
$byScope = collect($rows)->keyBy('scope_value');
$this->assertSame('mail', $byScope['first']['templates'][0]['channel']);
$this->assertSame('sms', $byScope['second']['templates'][0]['channel']);
$this->assertSame('push', $byScope['third']['templates'][0]['channel']);
}
/**
* 템플릿이 0건인 정의도 `templates` 는 빈 배열이다 (null 금지).
*
* 화면은 `(def.templates ?? []).find(...)` 로 읽으므로 null 이어도 즉시 깨지지는 않는다.
* 그러나 응답 계약상 null 은 "관계를 로드하지 않았다" 를 뜻하고 빈 배열은 "로드했더니 0건"
* 을 뜻한다. 두 상황이 같은 값으로 나오면 소비자가 둘을 구분할 수 없다.
*
* @return void
*
* @scenario resource=identity_message_definition,endpoint=list,templates=zero,observation=response_payload
* @effects identity_definition_zero_template_serializes_as_empty_array
*/
public function test_definition_without_any_template_serializes_templates_as_empty_array(): void
{
$this->makeDefinitionWithChannels('empty', []);
$row = $this->authRequest()
->getJson('/api/admin/identity/messages/definitions')
->assertStatus(200)
->json('data.data.0');
$this->assertArrayHasKey('templates', $row);
$this->assertSame(
[],
$row['templates'],
'null 은 "로드하지 않음" 을 뜻한다 — 0건은 빈 배열로 구분되어야 한다'
);
$this->assertSame(0, $row['templates_count']);
}
/**
* 목록의 대표 템플릿과 단건 조회의 첫 템플릿은 같은 행이다.
*
* 목록은 `firstTemplate`(MIN(id)) 로 대표를 고정하는데 단건의 `templates` 에 정렬이 없으면
* 첫 항목이 DB 반환 순서(채널 유니크 인덱스 순)를 탄다. 두 응답을 같은 `templates[0]` 계약으로
* 읽는 소비자는 목록에서 본 것과 다른 템플릿을 열게 된다.
*
* @return void
*
* @scenario resource=identity_message_definition,endpoint=list+detail,observation=response_payload
* @effects identity_definition_list_and_detail_agree_on_representative
*/
public function test_list_representative_matches_detail_first_template(): void
{
// 등록 순서(id)와 채널 사전순이 어긋나도록 만든다 — 정렬이 없으면 두 응답이 갈린다
$definition = $this->makeDefinitionWithChannels('order', ['sms', 'kakao']);
$listRow = collect(
$this->authRequest()
->getJson('/api/admin/identity/messages/definitions')
->json('data.data')
)->firstWhere('id', $definition->id);
$detail = $this->authRequest()
->getJson("/api/admin/identity/messages/definitions/{$definition->id}")
->assertStatus(200)
->json('data');
$detailIds = collect($detail['templates'])->pluck('id')->all();
$this->assertSame(
collect($detailIds)->sort()->values()->all(),
$detailIds,
'단건의 templates 는 등록 순서(id)로 정렬되어야 한다'
);
$this->assertSame(
$listRow['templates'][0]['id'],
$detail['templates'][0]['id'],
'목록에서 본 대표 템플릿과 단건의 첫 템플릿이 다르면 같은 계약을 읽는 소비자가 갈린다'
);
}
/**
* 템플릿 조회 쿼리 수가 정의 수에 비례하지 않는다 (N+1 부재).
*
* @return void
*
* @scenario resource=identity_message_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->makeDefinitionWithChannels('a', ['mail', 'sms']);
// 권한/설정 캐시를 채워 측정에서 제외한다
$this->authRequest()->getJson('/api/admin/identity/messages/definitions');
$countTemplateQueries = function (): int {
$count = 0;
DB::listen(function ($query) use (&$count) {
if (str_contains($query->sql, 'identity_message_templates')) {
$count++;
}
});
$this->authRequest()->getJson('/api/admin/identity/messages/definitions');
return $count;
};
$withOne = $countTemplateQueries();
foreach (['b', 'c', 'd', 'e', 'f'] as $key) {
$this->makeDefinitionWithChannels($key, ['mail', 'sms']);
}
$withSix = $countTemplateQueries();
$this->assertSame(
$withOne,
$withSix,
"정의 1건일 때 {$withOne}회, 6건일 때 {$withSix}회 — 정의 수에 비례하면 N+1 이다"
);
}
}