Files
Gnuboard7/tests/Unit/Extension/TemplateModifiedLayoutScopeTest.php
HeuJung 1eedbbdda1 fix(core,engine): OFFSET 순회 중 필터 컬럼 변경으로 인한 처리 누락 수정
Laravel chunk/each 는 OFFSET 기반이라, 콜백이 행의 필터 소속을 바꾸면
(필터 컬럼을 채우거나 매칭 행을 삭제하면) 다음 조회가 그만큼 앞으로 밀려
미처리 행을 조용히 건너뛴다. 250건/청크 100 이면 정확히 100건이 남는다.
코어·번들 확장 전수 조사로 확정한 6곳을 chunkById(키셋 순회)로 통일했다.

브라우저 검수(28축)에서 계획에 없던 결함 12건이 드러나 같은 사슬에서
수정했다. 이 중 layout_strategy=keep 무효화와 수정 감지 실패 시 "수정 없음"
오보는 사용자 작업 소실에 직결된다.

재발 방지로 audit 룰을 추가했고, 수정 6곳을 원복해 red 6건 / 복원 후 green
0건으로 판별력을 검증했다.
2026-07-31 21:50:45 +09:00

160 lines
6.4 KiB
PHP

<?php
namespace Tests\Unit\Extension;
use App\Enums\ExtensionStatus;
use App\Extension\TemplateManager;
use App\Extension\Traits\ComputesLayoutContentHash;
use App\Models\Template;
use App\Models\TemplateLayout;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
/**
* 템플릿 수정 레이아웃 감지의 소유 범위 회귀 테스트.
*
* 배경(Chrome MCP 정밀 점검에서 실측): `hasModifiedLayouts()` 가
* `getByTemplateId()` 로 해당 template_id 의 **모든** 레이아웃을 세어,
* 같은 템플릿에 등록된 모듈/플러그인 소유 레이아웃의 수정본까지 템플릿 자신의
* 수정으로 집계했다(실측: 모달 4건 중 3건이 board/gdpr/sample 소유).
* 실제 갱신 범위인 `refreshTemplateLayouts()` 는 source_type='template' +
* source_identifier=null 만 다루므로 표시와 동작이 어긋난다.
*
* 조회 소스의 철자가 아니라 **실제 집계 결과**로 범위를 고정한다.
*/
class TemplateModifiedLayoutScopeTest extends TestCase
{
use ComputesLayoutContentHash, RefreshDatabase;
private TemplateManager $templateManager;
private Template $template;
/** 배포 원본 콘텐츠 */
private const ORIGINAL = ['layout_name' => 'home', 'slots' => ['content' => []]];
/** 사용자가 UI 에서 수정한 콘텐츠 */
private const MODIFIED = ['layout_name' => 'home', 'slots' => ['content' => [['component' => 'Div']]]];
protected function setUp(): void
{
parent::setUp();
$this->templateManager = app(TemplateManager::class);
$this->template = Template::create([
'identifier' => 'test-scope-admin',
'vendor' => 'test',
'name' => ['ko' => '범위 테스트 관리자', 'en' => 'Scope Test Admin'],
'version' => '1.0.0',
'type' => 'admin',
'status' => ExtensionStatus::Active->value,
'description' => ['ko' => '테스트용', 'en' => 'For testing'],
]);
}
/**
* 레이아웃 1건을 시드합니다.
*
* original_content_hash 는 항상 배포 원본 기준으로 기록하고, content 만 수정본으로
* 바꿔 "사용자가 UI 에서 고친 상태" 를 재현합니다.
*
* @param int $templateId 소속 템플릿 ID
* @param string $name 레이아웃 이름
* @param string $sourceType 소유 구분 (template/module/plugin)
* @param string|null $sourceIdentifier 소유 확장 식별자 (템플릿 소유는 null)
* @param bool $modified true 면 수정된 상태로 시드
* @return TemplateLayout 생성된 레이아웃
*/
private function seedLayout(
int $templateId,
string $name,
string $sourceType,
?string $sourceIdentifier,
bool $modified
): TemplateLayout {
return TemplateLayout::create([
'template_id' => $templateId,
'name' => $name,
'content' => $modified ? self::MODIFIED : self::ORIGINAL,
'source_type' => $sourceType,
'source_identifier' => $sourceIdentifier,
'original_content_hash' => $this->computeContentHash(self::ORIGINAL),
'original_content_size' => $this->computeContentSize(self::ORIGINAL),
]);
}
/**
* 템플릿 소유 수정본만 집계하고 남의 확장 소유는 제외해야 한다.
*/
public function test_has_modified_layouts_counts_only_template_owned_layouts(): void
{
$this->seedLayout($this->template->id, 'admin_home', 'template', null, true);
// 같은 template_id 에 얹힌 남의 확장 소유 수정본 3건
$this->seedLayout($this->template->id, 'sirsoft-board.admin_index', 'module', 'sirsoft-board', true);
$this->seedLayout($this->template->id, 'sirsoft-page.admin_index', 'module', 'sirsoft-page', true);
$this->seedLayout($this->template->id, 'sirsoft-gdpr.admin_index', 'plugin', 'sirsoft-gdpr', true);
$result = $this->templateManager->hasModifiedLayouts('test-scope-admin');
$this->assertTrue($result['has_modified_layouts']);
$this->assertSame(
1,
$result['modified_count'],
'템플릿 업데이트가 실제로 갱신하지 않는 모듈/플러그인 소유 레이아웃이 수정 건수에 합산되었습니다.'
);
$this->assertSame(['admin_home'], array_column($result['modified_layouts'], 'name'));
}
/**
* 남의 확장만 수정된 상태에서는 경고가 뜨면 안 된다.
*/
public function test_has_modified_layouts_returns_zero_when_only_other_extensions_are_modified(): void
{
$this->seedLayout($this->template->id, 'admin_home', 'template', null, false);
$this->seedLayout($this->template->id, 'sirsoft-board.admin_index', 'module', 'sirsoft-board', true);
$result = $this->templateManager->hasModifiedLayouts('test-scope-admin');
$this->assertFalse($result['has_modified_layouts']);
$this->assertSame(0, $result['modified_count']);
$this->assertEmpty($result['modified_layouts']);
}
/**
* 다른 템플릿의 수정본은 집계 대상이 아니다.
*/
public function test_has_modified_layouts_ignores_layouts_of_other_templates(): void
{
$other = Template::create([
'identifier' => 'test-scope-user',
'vendor' => 'test',
'name' => ['ko' => '범위 테스트 사용자', 'en' => 'Scope Test User'],
'version' => '1.0.0',
'type' => 'user',
'status' => ExtensionStatus::Active->value,
'description' => ['ko' => '테스트용', 'en' => 'For testing'],
]);
$this->seedLayout($other->id, 'user_home', 'template', null, true);
$this->seedLayout($this->template->id, 'admin_home', 'template', null, false);
$result = $this->templateManager->hasModifiedLayouts('test-scope-admin');
$this->assertSame(0, $result['modified_count'], '다른 템플릿의 수정본까지 집계되었습니다.');
}
/**
* 미존재 템플릿은 빈 결과를 반환한다.
*/
public function test_has_modified_layouts_returns_empty_for_unknown_template(): void
{
$result = $this->templateManager->hasModifiedLayouts('no-such-template');
$this->assertFalse($result['has_modified_layouts']);
$this->assertSame(0, $result['modified_count']);
$this->assertEmpty($result['modified_layouts']);
}
}