공개 는 헤더 「로고 이미지」에 이미지를 지정하면 화면이 엑박이 되는 제보였다.
image 위젯은 배경용으로 설계되어 {url,size,repeat,position} 객체를 내보내는데, 값 슬롯이
하나뿐인 apply 경로(propValue/cssVar/단일 styleProp)가 그 객체를 그대로 props 에 기록해
소비 컴포넌트가 [object Object] 를 URL 로 받았다. 예외도 콘솔 오류도 서버 로그도 남지
않는다 — 깨진 이미지 요청은 SPA catch-all 때문에 404 조차 아니라 200(HTML)이고, 편집기
미리보기는 정상이라 조작 중에는 이상이 보이지 않는다.
방어선을 넷으로 세웠다. 쓰기 축약(공용 헬퍼 scalarizeImageValue 단일 지점, 게이트는 위젯
이름이며 값 형태 sniffing 이 아니다) · 읽기 역조립(표현식 문자열도 되감아 업로드 1클릭에
소실되지 않게 한다) · 런타임 방어(업그레이드 전 화면을 위해 템플릿 Img 가 url 을 해석하고
손상값이면 src 를 아예 붙이지 않는다) · 저장 데이터 백필(업그레이드 스텝). 런타임과 백필은
완전히 같은 엄격 판정식(키 집합 ⊆ 4키 AND url 보유)을 쓴다 — 엔진의 느슨한 판정식을 백필에
이식하면 레이아웃 전수에서 정상 props 2,219건을 파괴한다(실측).
전수조사에서 파생한 인접 결함 넷을 함께 고쳤다.
- number 위젯이 코어 레지스트리에 미등록이라 「탭 표시 게시판 수」 같은 컨트롤이
「지원하지 않는 컨트롤」로 폴백해 편집 자체가 불가했다. nodeKey apply 는 coreProps 가
선언만 하고 엔진 switch 에 case 가 없어 무음 no-op 이었다.
- 상속(base)·주입(extension) 노드 중 바인딩을 가진 것이 data_bound 로 분류돼 편집이
열려 있었는데, 저장 마스킹이 그 노드를 통째로 폐기하므로 편집분이 오류도 경고도 없이
사라졌다(저장은 200 이고 history 는 clear 돼 undo 도 불가). 출처 잠금이 항상 우선하도록
판정 순서를 통일하고, 단일 판정 헬퍼로 인라인 편집·복제·Delete·잘라내기·드래그 commit
까지 전 표면을 같은 기준으로 막았다.
- prop 자리의 표현식 값을 위젯이 해석하지 못해 빈 컨트롤로 보이고, 조작하는 순간 환경설정
과의 연결이 소리 없이 끊겼다. 판정·배지·잠금·해제·복구를 ControlRenderer 공용 게이트
한 곳으로 올려 신규 위젯에도 자동 적용되게 했다. 「직접 지정으로 바꾸기」에는 「되돌리기」를
동반해 편도가 되지 않게 한다.
- 편집기 모드에서 updateTemplateData 가 빈 레이아웃으로 같은 reactRoot 에 두 번째 커밋을
걸어 편집기 트리를 통째로 제거했다. renderTemplate 의 편집기 분기가 비동기라 부팅 중
setGlobalState 가 그 커밋 뒤에 도착할 때만 발현하는 경합이었다.
699 lines
29 KiB
PHP
699 lines
29 KiB
PHP
<?php
|
|
|
|
// @scenario apply_type=propValue, consumer=header_logo, storage_scope=layout_previews_untouched, stored_shape=binding_expression, widget_output=scalar_string
|
|
// @scenario apply_type=propValue, consumer=img_src, storage_scope=layout_previews_untouched, stored_shape=object, widget_output=image_object_nonstring_url
|
|
// @scenario apply_type=propValue, consumer=none, storage_scope=layout_previews_untouched, stored_shape=string, widget_output=image_object
|
|
|
|
namespace Tests\Feature\Upgrades;
|
|
|
|
use App\Extension\UpgradeContext;
|
|
use App\Upgrades\Data\V7_0_11\Migrations\NarrowImageObjectPropsInLayouts;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Log;
|
|
use Illuminate\Support\Facades\Schema;
|
|
use Mockery\MockInterface;
|
|
use Psr\Log\LoggerInterface;
|
|
use ReflectionClass;
|
|
use Tests\TestCase;
|
|
|
|
/**
|
|
* 7.0.11 업그레이드 스텝 — prop 자리 이미지 값 객체 축약 백필 검증.
|
|
*
|
|
* 레이아웃 편집기의 `image` 위젯이 내보내는 `{url,size,repeat,position}` 객체가
|
|
* `propValue` 경로로 `props[key]` 에 그대로 기록되어 소비 컴포넌트가
|
|
* `<Img src={객체}>` 를 받던 결함(공개 #135)의 기존 데이터 보정.
|
|
*
|
|
* 검증 축은 셋이다:
|
|
* ① **판정식 엄격도** — 느슨한 판정식(4키 중 하나라도)을 쓰면 정상 props 를 파괴한다.
|
|
* 케이스 9·10·11·14 가 그 반증 가드다.
|
|
* ② **순회 범위** — prop 자리는 키 allowlist 로 정의 불가하므로 모드 플래그 전역 재귀.
|
|
* 중첩 노드·responsive 분기·확장 injections 가 포섭되고 style 자리는 배제된다.
|
|
* ③ **안전성** — 멱등 / 무변경 행 UPDATE 금지 / chunkById / lock_version /
|
|
* original_content_hash 불변 / 버전·미리보기 스냅샷 미개입.
|
|
*/
|
|
class Upgrade7011ImageObjectPropBackfillTest extends TestCase
|
|
{
|
|
use RefreshDatabase;
|
|
|
|
/** 청크 크기(100) 를 넘겨야 OFFSET 누락이 드러난다 — Beta2LayoutHashBackfillTest 관례 계승 */
|
|
private const SEED_COUNT = 250;
|
|
|
|
private NarrowImageObjectPropsInLayouts $migration;
|
|
|
|
private UpgradeContext $context;
|
|
|
|
private int $templateId;
|
|
|
|
protected function setUp(): void
|
|
{
|
|
parent::setUp();
|
|
|
|
// upgrade data 파일은 composer autoload 대상이 아니며 AbstractUpgradeStep 이
|
|
// 실행 시점에 require_once 로 수동 로드한다. 테스트에서도 동일하게 수동 로드.
|
|
require_once base_path('upgrades/data/7.0.11/migrations/01_NarrowImageObjectPropsInLayouts.php');
|
|
|
|
$this->migration = new NarrowImageObjectPropsInLayouts;
|
|
$this->context = new UpgradeContext(
|
|
fromVersion: '7.0.10',
|
|
toVersion: '7.0.11',
|
|
currentStep: '7.0.11',
|
|
);
|
|
|
|
$this->templateId = DB::table('templates')->insertGetId([
|
|
'identifier' => 'test-imgobj_template',
|
|
'vendor' => 'test',
|
|
'name' => json_encode(['ko' => '이미지 축약 테스트', 'en' => 'Image Narrow Test']),
|
|
'version' => '1.0.0',
|
|
'type' => 'user',
|
|
'status' => 'active',
|
|
'created_at' => now(),
|
|
'updated_at' => now(),
|
|
]);
|
|
}
|
|
|
|
/** 완전한 4키 이미지 값 객체 */
|
|
private function imageValue(string $url = '/api/attachment/X'): array
|
|
{
|
|
return ['url' => $url, 'size' => 'cover', 'repeat' => 'no-repeat', 'position' => 'center'];
|
|
}
|
|
|
|
/**
|
|
* 레이아웃 1건을 시드하고 id 를 돌려줍니다.
|
|
*
|
|
* @param array $content 레이아웃 content 배열
|
|
* @param array $extra 추가 컬럼 오버라이드
|
|
* @return int 생성된 레이아웃 id
|
|
*/
|
|
private function seedLayout(array $content, array $extra = []): int
|
|
{
|
|
static $seq = 0;
|
|
$seq++;
|
|
|
|
return DB::table('template_layouts')->insertGetId(array_merge([
|
|
'template_id' => $this->templateId,
|
|
'name' => 'imgobj/layout_'.$seq,
|
|
'content' => json_encode($content, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
|
|
'source_type' => 'template',
|
|
'source_identifier' => 'test-imgobj_template',
|
|
'lock_version' => 0,
|
|
'created_at' => now(),
|
|
'updated_at' => now(),
|
|
], $extra));
|
|
}
|
|
|
|
/** 저장된 content 를 배열로 되읽는다 */
|
|
private function readContent(int $id, string $table = 'template_layouts'): array
|
|
{
|
|
return json_decode(DB::table($table)->where('id', $id)->value('content'), true);
|
|
}
|
|
|
|
private function runMigration(): void
|
|
{
|
|
$this->migration->run($this->context);
|
|
}
|
|
|
|
/**
|
|
* 로거를 감시 대역으로 바꾼 컨텍스트로 마이그레이션을 실행합니다.
|
|
*
|
|
* 파괴적 동작(prop 키 제거)의 유일한 흔적은 `upgrade` 채널 로그다 — 그 호출이
|
|
* 사라져도 데이터 단언만으로는 드러나지 않으므로 로그 발생 자체를 계약으로 고정한다.
|
|
* `UpgradeContext` 는 생성자에서 `Log::channel()` 을 1회 해석하므로 페이크를
|
|
* **컨텍스트 생성 전에** 심어야 한다.
|
|
*
|
|
* @return MockInterface 감시 대역 로거
|
|
*/
|
|
private function runMigrationWithLogSpy(): MockInterface
|
|
{
|
|
$spy = \Mockery::spy(LoggerInterface::class);
|
|
Log::shouldReceive('channel')
|
|
->with('upgrade')
|
|
->andReturn($spy);
|
|
|
|
$this->migration->run(new UpgradeContext(
|
|
fromVersion: '7.0.10',
|
|
toVersion: '7.0.11',
|
|
currentStep: '7.0.11',
|
|
));
|
|
|
|
return $spy;
|
|
}
|
|
|
|
// ── ① 기본 축약 ────────────────────────────────────────────────────────
|
|
|
|
/** @effects backfill_narrows_prop_slot_objects_in_layouts_and_extensions */
|
|
public function test_1_prop_slot_image_object_is_narrowed_to_url(): void
|
|
{
|
|
$id = $this->seedLayout([
|
|
'components' => [
|
|
['name' => 'Header', 'props' => ['logo' => $this->imageValue(), 'siteName' => '샘플']],
|
|
],
|
|
]);
|
|
|
|
$this->runMigration();
|
|
|
|
$content = $this->readContent($id);
|
|
$this->assertSame('/api/attachment/X', $content['components'][0]['props']['logo']);
|
|
$this->assertSame('샘플', $content['components'][0]['props']['siteName'], '나머지 props 는 불변이어야 합니다.');
|
|
}
|
|
|
|
public function test_2_string_value_is_untouched_and_row_is_not_updated(): void
|
|
{
|
|
$id = $this->seedLayout([
|
|
'components' => [['name' => 'Header', 'props' => ['logo' => '/img/a.png']]],
|
|
]);
|
|
$before = DB::table('template_layouts')->where('id', $id)->first();
|
|
|
|
$this->runMigration();
|
|
|
|
$after = DB::table('template_layouts')->where('id', $id)->first();
|
|
$this->assertSame($before->content, $after->content);
|
|
$this->assertSame($before->updated_at, $after->updated_at, '무변경 행은 UPDATE 되지 않아야 합니다.');
|
|
$this->assertSame($before->lock_version, $after->lock_version);
|
|
}
|
|
|
|
public function test_3_binding_expression_string_is_untouched(): void
|
|
{
|
|
$expr = '{{_global.settings?.general?.site_logo_url}}';
|
|
$id = $this->seedLayout([
|
|
'components' => [['name' => 'Header', 'props' => ['logo' => $expr]]],
|
|
]);
|
|
$before = DB::table('template_layouts')->where('id', $id)->first();
|
|
|
|
$this->runMigration();
|
|
|
|
$after = DB::table('template_layouts')->where('id', $id)->first();
|
|
$this->assertSame($expr, $this->readContent($id)['components'][0]['props']['logo']);
|
|
$this->assertSame($before->updated_at, $after->updated_at);
|
|
}
|
|
|
|
// ── ② style 자리는 대상이 아니다 ───────────────────────────────────────
|
|
|
|
/** @effects backfill_excludes_style_slot_where_background_decomposition_lives */
|
|
public function test_4_style_background_four_props_are_untouched(): void
|
|
{
|
|
$style = [
|
|
'backgroundImage' => 'url(/a.png)',
|
|
'backgroundSize' => 'cover',
|
|
'backgroundRepeat' => 'no-repeat',
|
|
'backgroundPosition' => 'center',
|
|
];
|
|
$id = $this->seedLayout([
|
|
'components' => [['name' => 'Div', 'props' => ['style' => $style]]],
|
|
]);
|
|
$before = DB::table('template_layouts')->where('id', $id)->first();
|
|
|
|
$this->runMigration();
|
|
|
|
$this->assertSame($style, $this->readContent($id)['components'][0]['props']['style']);
|
|
$this->assertSame($before->updated_at, DB::table('template_layouts')->where('id', $id)->first()->updated_at);
|
|
}
|
|
|
|
public function test_5_corrupted_value_inside_style_slot_is_untouched(): void
|
|
{
|
|
// style 자리의 손상값 복구는 엔진 `sanitizeBgScalar` 소관 — 백필은 건드리지 않는다.
|
|
$style = ['backgroundPosition' => $this->imageValue()];
|
|
$id = $this->seedLayout([
|
|
'components' => [['name' => 'Div', 'props' => ['style' => $style]]],
|
|
]);
|
|
|
|
$this->runMigration();
|
|
|
|
$this->assertSame($style, $this->readContent($id)['components'][0]['props']['style']);
|
|
}
|
|
|
|
// ── ③ 순회 범위 ────────────────────────────────────────────────────────
|
|
|
|
/** @effects backfill_traverses_responsive_branches_nested_nodes_and_injections */
|
|
public function test_6_responsive_branch_props_are_narrowed(): void
|
|
{
|
|
$id = $this->seedLayout([
|
|
'components' => [
|
|
[
|
|
'name' => 'Header',
|
|
'responsive' => ['md' => ['props' => ['logo' => $this->imageValue('/md.png')]]],
|
|
],
|
|
],
|
|
]);
|
|
|
|
$this->runMigration();
|
|
|
|
$this->assertSame('/md.png', $this->readContent($id)['components'][0]['responsive']['md']['props']['logo']);
|
|
}
|
|
|
|
public function test_7_nested_node_inside_props_is_narrowed(): void
|
|
{
|
|
$id = $this->seedLayout([
|
|
'components' => [
|
|
[
|
|
'name' => 'DataGrid',
|
|
'props' => [
|
|
'cardColumns' => [
|
|
['cellChildren' => [['name' => 'Header', 'props' => ['logo' => $this->imageValue('/nested.png')]]]],
|
|
],
|
|
],
|
|
],
|
|
],
|
|
]);
|
|
|
|
$this->runMigration();
|
|
|
|
$content = $this->readContent($id);
|
|
$this->assertSame(
|
|
'/nested.png',
|
|
$content['components'][0]['props']['cardColumns'][0]['cellChildren'][0]['props']['logo']
|
|
);
|
|
}
|
|
|
|
public function test_8_style_inside_nested_node_is_still_excluded(): void
|
|
{
|
|
$style = ['backgroundImage' => 'url(/n.png)', 'backgroundSize' => 'cover'];
|
|
$id = $this->seedLayout([
|
|
'components' => [
|
|
[
|
|
'name' => 'Div',
|
|
'props' => [
|
|
'expandChildren' => [['name' => 'Div', 'props' => ['style' => $style]]],
|
|
],
|
|
],
|
|
],
|
|
]);
|
|
|
|
$this->runMigration();
|
|
|
|
$content = $this->readContent($id);
|
|
$this->assertSame($style, $content['components'][0]['props']['expandChildren'][0]['props']['style']);
|
|
}
|
|
|
|
// ── ④ 판정식 엄격도 (느슨한 판정식이면 여기서 파괴된다) ───────────────
|
|
|
|
/** @effects backfill_strict_predicate_leaves_normal_props_untouched */
|
|
public function test_9_normal_props_with_size_key_are_untouched(): void
|
|
{
|
|
// 실측 674건 패턴. 코어 엔진의 느슨한 판정식(4키 중 하나라도)이면 이 props 가
|
|
// 이미지 값 객체로 오인되어 파괴된다.
|
|
$props = ['className' => 'x', 'name' => 'user', 'size' => 'lg'];
|
|
$id = $this->seedLayout(['components' => [['name' => 'Icon', 'props' => $props]]]);
|
|
$before = DB::table('template_layouts')->where('id', $id)->first();
|
|
|
|
$this->runMigration();
|
|
|
|
$this->assertSame($props, $this->readContent($id)['components'][0]['props']);
|
|
$this->assertSame($before->updated_at, DB::table('template_layouts')->where('id', $id)->first()->updated_at);
|
|
}
|
|
|
|
public function test_10_normal_props_with_position_key_are_untouched(): void
|
|
{
|
|
// 실측 26건 패턴.
|
|
$props = ['items' => [['label' => 'a']], 'position' => 'bottom'];
|
|
$id = $this->seedLayout(['components' => [['name' => 'Toast', 'props' => $props]]]);
|
|
|
|
$this->runMigration();
|
|
|
|
$this->assertSame($props, $this->readContent($id)['components'][0]['props']);
|
|
}
|
|
|
|
public function test_11_object_with_key_outside_the_four_is_untouched(): void
|
|
{
|
|
$value = ['url' => '/x', 'label' => 'y'];
|
|
$id = $this->seedLayout(['components' => [['name' => 'X', 'props' => ['foo' => $value]]]]);
|
|
|
|
$this->runMigration();
|
|
|
|
$this->assertSame($value, $this->readContent($id)['components'][0]['props']['foo']);
|
|
}
|
|
|
|
public function test_14_image_object_without_url_key_is_untouched(): void
|
|
{
|
|
// `{size,repeat,position}` 은 정상 props 와 정적으로 구분 불가 — 건드리면 오탐.
|
|
$value = ['size' => 'cover', 'repeat' => 'no-repeat', 'position' => 'center'];
|
|
$id = $this->seedLayout(['components' => [['name' => 'X', 'props' => ['bg' => $value]]]]);
|
|
|
|
$this->runMigration();
|
|
|
|
$this->assertSame($value, $this->readContent($id)['components'][0]['props']['bg']);
|
|
}
|
|
|
|
public function test_27_props_container_itself_is_never_treated_as_an_image_value(): void
|
|
{
|
|
// `props` 는 값이 아니라 **컨테이너**다. `url` prop 을 받는 컴포넌트(임베드·영상 등)는
|
|
// props 맵 자신이 「4키 부분집합 + url 보유」 형태가 되는데, 그것을 값으로 오인하면
|
|
// props 가 통째로 문자열로 붕괴해 그 노드의 설정이 전부 사라진다.
|
|
$props = ['url' => '/embed/x', 'size' => 'lg'];
|
|
$id = $this->seedLayout(['components' => [['name' => 'Iframe', 'props' => $props]]]);
|
|
|
|
$this->runMigration();
|
|
|
|
$this->assertSame($props, $this->readContent($id)['components'][0]['props']);
|
|
}
|
|
|
|
public function test_28_props_container_with_corrupted_url_is_never_dropped(): void
|
|
{
|
|
// 같은 오인이 손상 url 분기로 가면 props 키 자체가 unset 되어 노드가 빈 껍데기가 된다.
|
|
$props = ['url' => ['nested' => 1], 'size' => 'lg'];
|
|
$id = $this->seedLayout(['components' => [['name' => 'Video', 'props' => $props]]]);
|
|
|
|
$this->runMigration();
|
|
|
|
$node = $this->readContent($id)['components'][0];
|
|
$this->assertArrayHasKey('props', $node, 'props 컨테이너는 손상 url 분기의 대상이 아니다.');
|
|
$this->assertSame($props, $node['props']);
|
|
}
|
|
|
|
public function test_29_style_container_itself_is_never_treated_as_an_image_value(): void
|
|
{
|
|
// `style` 도 같은 컨테이너 키다 — prop 자리 안에 있어도 값으로 평가하지 않는다.
|
|
$style = ['url' => '/legacy.png', 'position' => 'center'];
|
|
$id = $this->seedLayout(['components' => [['name' => 'X', 'props' => ['style' => $style]]]]);
|
|
|
|
$this->runMigration();
|
|
|
|
$this->assertSame($style, $this->readContent($id)['components'][0]['props']['style']);
|
|
}
|
|
|
|
// ── ⑤ 손상 url ─────────────────────────────────────────────────────────
|
|
|
|
/** @effects backfill_drops_prop_key_on_corrupted_url_with_warning_log */
|
|
public function test_12_empty_url_drops_the_prop_key(): void
|
|
{
|
|
$id = $this->seedLayout([
|
|
'components' => [['name' => 'Header', 'props' => ['logo' => $this->imageValue(''), 'siteName' => '샘플']]],
|
|
]);
|
|
|
|
$spy = $this->runMigrationWithLogSpy();
|
|
|
|
$props = $this->readContent($id)['components'][0]['props'];
|
|
$this->assertArrayNotHasKey('logo', $props, 'src="" 는 현재 문서 재요청이므로 키를 제거해 폴백을 살린다.');
|
|
$this->assertSame('샘플', $props['siteName']);
|
|
|
|
// 파괴적 동작이므로 원본이 로그에 남아야 한다 — 행 id·JSON 경로·원본 값 3요소.
|
|
$spy->shouldHaveReceived('warning')
|
|
->withArgs(fn (string $m) => str_contains($m, "id={$id}")
|
|
&& str_contains($m, 'components[0].props.logo')
|
|
&& str_contains($m, '"url":""'))
|
|
->once();
|
|
}
|
|
|
|
public function test_13_non_string_url_drops_the_prop_key(): void
|
|
{
|
|
$id = $this->seedLayout([
|
|
'components' => [['name' => 'Header', 'props' => ['logo' => ['url' => ['nested' => 1], 'size' => 'cover']]]],
|
|
]);
|
|
|
|
$spy = $this->runMigrationWithLogSpy();
|
|
|
|
$this->assertArrayNotHasKey('logo', $this->readContent($id)['components'][0]['props']);
|
|
|
|
$spy->shouldHaveReceived('warning')
|
|
->withArgs(fn (string $m) => str_contains($m, "id={$id}")
|
|
&& str_contains($m, 'components[0].props.logo')
|
|
&& str_contains($m, '"nested":1'))
|
|
->once();
|
|
}
|
|
|
|
// ── ⑥ 확장 테이블 ──────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* 확장 레이아웃 1건을 시드합니다.
|
|
*
|
|
* @param array $content content 배열
|
|
* @return int 생성된 행 id
|
|
*/
|
|
private function seedExtension(array $content): int
|
|
{
|
|
static $seq = 0;
|
|
$seq++;
|
|
|
|
return DB::table('template_layout_extensions')->insertGetId([
|
|
'template_id' => $this->templateId,
|
|
'extension_type' => 'module',
|
|
'target_name' => 'home_'.$seq,
|
|
'source_type' => 'module',
|
|
'source_identifier' => 'test-module',
|
|
'content' => json_encode($content, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
|
|
'priority' => 10,
|
|
'is_active' => true,
|
|
'lock_version' => 0,
|
|
'created_at' => now(),
|
|
'updated_at' => now(),
|
|
]);
|
|
}
|
|
|
|
public function test_15_extension_injection_component_props_are_narrowed(): void
|
|
{
|
|
$id = $this->seedExtension([
|
|
'injections' => [
|
|
['components' => [['name' => 'Header', 'props' => ['logo' => $this->imageValue('/ext.png')]]]],
|
|
],
|
|
]);
|
|
|
|
$this->runMigration();
|
|
|
|
$content = $this->readContent($id, 'template_layout_extensions');
|
|
$this->assertSame('/ext.png', $content['injections'][0]['components'][0]['props']['logo']);
|
|
}
|
|
|
|
public function test_16_extension_inject_props_slot_is_narrowed(): void
|
|
{
|
|
$id = $this->seedExtension([
|
|
'injections' => [['props' => ['logo' => $this->imageValue('/inject.png')]]],
|
|
]);
|
|
|
|
$this->runMigration();
|
|
|
|
$content = $this->readContent($id, 'template_layout_extensions');
|
|
$this->assertSame('/inject.png', $content['injections'][0]['props']['logo']);
|
|
}
|
|
|
|
/** @effects backfill_traverses_modals_in_both_list_and_map_shape */
|
|
public function test_17_modals_in_both_list_and_map_shape_are_narrowed(): void
|
|
{
|
|
$listId = $this->seedLayout([
|
|
'modals' => [['id' => 'm1', 'components' => [['name' => 'Img', 'props' => ['src' => $this->imageValue('/list.png')]]]]],
|
|
]);
|
|
$mapId = $this->seedLayout([
|
|
'modals' => [
|
|
'channel_edit_modal' => [
|
|
'components' => [['name' => 'Img', 'props' => ['src' => $this->imageValue('/map.png')]]],
|
|
],
|
|
],
|
|
]);
|
|
|
|
$this->runMigration();
|
|
|
|
$this->assertSame('/list.png', $this->readContent($listId)['modals'][0]['components'][0]['props']['src']);
|
|
$this->assertSame(
|
|
'/map.png',
|
|
$this->readContent($mapId)['modals']['channel_edit_modal']['components'][0]['props']['src']
|
|
);
|
|
}
|
|
|
|
// ── ⑦ 안전성 ───────────────────────────────────────────────────────────
|
|
|
|
/** @effects backfill_is_idempotent_and_performs_no_update_on_unchanged_rows */
|
|
public function test_18_second_run_is_idempotent_and_performs_no_update(): void
|
|
{
|
|
$id = $this->seedLayout([
|
|
'components' => [['name' => 'Header', 'props' => ['logo' => $this->imageValue()]]],
|
|
]);
|
|
|
|
$this->runMigration();
|
|
$afterFirst = DB::table('template_layouts')->where('id', $id)->first();
|
|
|
|
$this->runMigration();
|
|
$afterSecond = DB::table('template_layouts')->where('id', $id)->first();
|
|
|
|
$this->assertSame($afterFirst->content, $afterSecond->content);
|
|
$this->assertSame($afterFirst->lock_version, $afterSecond->lock_version, '2회차는 UPDATE 가 없어야 합니다.');
|
|
$this->assertSame($afterFirst->updated_at, $afterSecond->updated_at);
|
|
}
|
|
|
|
/** @effects backfill_increments_lock_version_only_on_changed_rows */
|
|
public function test_19_lock_version_is_incremented_only_on_changed_rows(): void
|
|
{
|
|
$dirty = $this->seedLayout(
|
|
['components' => [['name' => 'Header', 'props' => ['logo' => $this->imageValue()]]]],
|
|
['lock_version' => 3],
|
|
);
|
|
$clean = $this->seedLayout(
|
|
['components' => [['name' => 'Header', 'props' => ['logo' => '/ok.png']]]],
|
|
['lock_version' => 3],
|
|
);
|
|
|
|
$this->runMigration();
|
|
|
|
$this->assertSame(4, (int) DB::table('template_layouts')->where('id', $dirty)->value('lock_version'));
|
|
$this->assertSame(3, (int) DB::table('template_layouts')->where('id', $clean)->value('lock_version'));
|
|
}
|
|
|
|
/** @effects backfill_preserves_original_content_hash_so_modified_judgement_holds */
|
|
public function test_20_original_content_hash_is_preserved(): void
|
|
{
|
|
$id = $this->seedLayout(
|
|
['components' => [['name' => 'Header', 'props' => ['logo' => $this->imageValue()]]]],
|
|
['original_content_hash' => str_repeat('a', 64), 'original_content_size' => 123],
|
|
);
|
|
|
|
$this->runMigration();
|
|
|
|
$row = DB::table('template_layouts')->where('id', $id)->first();
|
|
$this->assertSame(str_repeat('a', 64), $row->original_content_hash, '재계산하면 사용자 편집본이 「원본 그대로」로 위장된다.');
|
|
$this->assertSame(123, (int) $row->original_content_size);
|
|
// 백필 후에도 content 는 원본 파일과 다르므로 「수정됨」 판정이 유지된다.
|
|
$this->assertNotSame(hash('sha256', $row->content), $row->original_content_hash);
|
|
}
|
|
|
|
/** @effects backfill_uses_keyset_chunking_so_no_row_is_skipped_past_the_boundary */
|
|
public function test_21_all_rows_are_converted_beyond_the_chunk_boundary(): void
|
|
{
|
|
$ids = [];
|
|
for ($i = 0; $i < self::SEED_COUNT; $i++) {
|
|
$ids[] = $this->seedLayout([
|
|
'components' => [['name' => 'Header', 'props' => ['logo' => $this->imageValue('/a'.$i.'.png')]]],
|
|
]);
|
|
}
|
|
|
|
$this->runMigration();
|
|
|
|
$remaining = 0;
|
|
foreach ($ids as $i => $id) {
|
|
$logo = $this->readContent($id)['components'][0]['props']['logo'];
|
|
if (! is_string($logo)) {
|
|
$remaining++;
|
|
|
|
continue;
|
|
}
|
|
$this->assertSame('/a'.$i.'.png', $logo);
|
|
}
|
|
|
|
$this->assertSame(0, $remaining, 'OFFSET 순회면 청크 경계 이후 행이 조용히 누락된다 (chunkById 필요).');
|
|
}
|
|
|
|
/** @effects backfill_skips_broken_json_rows_without_aborting_the_run */
|
|
public function test_22_broken_json_row_is_skipped_without_aborting_the_run(): void
|
|
{
|
|
$broken = $this->seedLayout(['components' => []]);
|
|
DB::table('template_layouts')->where('id', $broken)->update(['content' => '{not json']);
|
|
$good = $this->seedLayout([
|
|
'components' => [['name' => 'Header', 'props' => ['logo' => $this->imageValue('/good.png')]]],
|
|
]);
|
|
|
|
$spy = $this->runMigrationWithLogSpy();
|
|
|
|
$this->assertSame('{not json', DB::table('template_layouts')->where('id', $broken)->value('content'));
|
|
$this->assertSame('/good.png', $this->readContent($good)['components'][0]['props']['logo']);
|
|
|
|
// 건너뛴 행은 조용히 사라지면 안 된다 — 그 행 id 가 로그에 남아야 운영자가 찾아간다.
|
|
$spy->shouldHaveReceived('warning')
|
|
->withArgs(fn (string $m) => str_contains($m, "id={$broken}"))
|
|
->atLeast()->once();
|
|
}
|
|
|
|
/** @effects backfill_leaves_version_and_preview_snapshots_untouched */
|
|
public function test_23_version_and_preview_snapshots_are_untouched(): void
|
|
{
|
|
$layoutId = $this->seedLayout(['components' => []]);
|
|
$poisoned = json_encode(
|
|
['components' => [['name' => 'Header', 'props' => ['logo' => $this->imageValue('/snap.png')]]]],
|
|
JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES,
|
|
);
|
|
|
|
$versionId = DB::table('template_layout_versions')->insertGetId([
|
|
'layout_id' => $layoutId,
|
|
'version' => 1,
|
|
'content' => $poisoned,
|
|
'created_at' => now(),
|
|
]);
|
|
$previewId = DB::table('template_layout_previews')->insertGetId([
|
|
'token' => 'imgobj-preview-token',
|
|
'template_id' => $this->templateId,
|
|
'layout_name' => 'imgobj/preview',
|
|
'preview_type' => 'layout',
|
|
'content' => $poisoned,
|
|
'admin_id' => 1,
|
|
'expires_at' => now()->addHour(),
|
|
'created_at' => now(),
|
|
]);
|
|
|
|
$this->runMigration();
|
|
|
|
$this->assertSame(
|
|
$poisoned,
|
|
DB::table('template_layout_versions')->where('id', $versionId)->value('content'),
|
|
'과거 스냅샷을 고치면 「이전 버전으로 되돌리기」가 원본과 달라진다.'
|
|
);
|
|
$this->assertSame(
|
|
$poisoned,
|
|
DB::table('template_layout_previews')->where('id', $previewId)->value('content'),
|
|
'미리보기 스냅샷도 백필 대상이 아니다.'
|
|
);
|
|
}
|
|
|
|
/** @effects backfill_converts_soft_deleted_rows_for_restore_safety */
|
|
public function test_24_soft_deleted_rows_are_converted(): void
|
|
{
|
|
$id = $this->seedLayout(
|
|
['components' => [['name' => 'Header', 'props' => ['logo' => $this->imageValue('/deleted.png')]]]],
|
|
['deleted_at' => now()],
|
|
);
|
|
|
|
$this->runMigration();
|
|
|
|
$this->assertSame(
|
|
'/deleted.png',
|
|
$this->readContent($id)['components'][0]['props']['logo'],
|
|
'삭제된 레이아웃은 복원 가능하므로 고치지 않으면 복원 시 결함이 되살아난다.'
|
|
);
|
|
}
|
|
|
|
public function test_25_missing_table_is_skipped_without_error(): void
|
|
{
|
|
// 실제 `Schema::drop` 은 쓰지 않는다 — 이 테이블은 versions 테이블의 FK 대상이라
|
|
// 드롭 자체가 실패하고, DDL 은 RefreshDatabase 트랜잭션을 암묵 커밋시켜 뒤따르는
|
|
// 테스트까지 오염시킨다. 검증 대상은 `Schema::hasTable` 게이트이므로 존재하지 않는
|
|
// 테이블명으로 같은 코드 경로를 직접 태운다.
|
|
$ref = new ReflectionClass($this->migration);
|
|
$method = $ref->getMethod('processTable');
|
|
$method->setAccessible(true);
|
|
|
|
$this->assertFalse(Schema::hasTable('g7_no_such_layout_table'));
|
|
$method->invoke($this->migration, $this->context, 'g7_no_such_layout_table');
|
|
|
|
// 예외 없이 통과 + 실제 테이블은 그대로다.
|
|
$this->assertTrue(Schema::hasTable('template_layout_extensions'));
|
|
}
|
|
|
|
// ── ⑧ 변환 함수 직접 호출 (DB 없이 판정식만) ──────────────────────────
|
|
|
|
public function test_26_transform_is_callable_directly_and_matches_db_path_results(): void
|
|
{
|
|
$ref = new ReflectionClass($this->migration);
|
|
$method = $ref->getMethod('transform');
|
|
$method->setAccessible(true);
|
|
|
|
$call = function (array $input) use ($method) {
|
|
$changed = 0;
|
|
$args = [$input, false, &$changed, $this->context, 't', '1', ''];
|
|
|
|
return [$method->invokeArgs($this->migration, $args), $changed];
|
|
};
|
|
|
|
// 케이스 1 — 축약
|
|
[$out, $changed] = $call(['components' => [['props' => ['logo' => $this->imageValue()]]]]);
|
|
$this->assertSame('/api/attachment/X', $out['components'][0]['props']['logo']);
|
|
$this->assertSame(1, $changed);
|
|
|
|
// 케이스 4 — style 자리 불변
|
|
$style = ['backgroundImage' => 'url(/a.png)', 'backgroundSize' => 'cover'];
|
|
[$out, $changed] = $call(['components' => [['props' => ['style' => $style]]]]);
|
|
$this->assertSame($style, $out['components'][0]['props']['style']);
|
|
$this->assertSame(0, $changed);
|
|
|
|
// 케이스 9 — 정상 props 불변
|
|
$props = ['className' => 'x', 'name' => 'user', 'size' => 'lg'];
|
|
[$out, $changed] = $call(['components' => [['props' => $props]]]);
|
|
$this->assertSame($props, $out['components'][0]['props']);
|
|
$this->assertSame(0, $changed);
|
|
}
|
|
}
|