fix(core): 레이아웃 버전 변경량 계산의 메모리 초과와 편집기 확장 편집·재로드·충돌 안내·버전 기록 결함 수정
- 버전 이력 변경량(+N/-N 줄) 계산이 변경 영역 (줄 수)² 크기의 LCS 표를 만들어 큰 공통 레이아웃의 첫 편집기 저장이 PHP 기본 메모리 한도(128M) 서버에서 500 으로 끝났다. 두 행 DP 로 길이만 구하도록 바꿔 메모리가 줄 수에 비례하고 표시 숫자는 종전과 같다(참조 구현 동치·메모리 상한 회귀 테스트) - 확장 편집 모드 저장이 overlay 확장의 injections 를 비우던 결함: 서버가 주입 노드 출처 메타에 injection 순번을 싣고, 편집기는 그 순번(없으면 원본 노드 id) 으로 되돌리며, 되돌릴 수 없는 노드가 있으면 저장하지 않고 안내한다 - 서빙 캐시 키를 서버 현재 확장 캐시 버전으로만 조립해 저장·복원 두 번째부터 재로드·「최신 불러오기」가 옛 내용을 받던 결함 수정(요청 v 는 HTTP 캐시 우회용) - 409 충돌 안내가 errors 아래의 버전을 읽지 못해 「최신 버전: -1」 로 표시되던 결함을 세 저장 경로 공용 판독으로 수정 - 버전 저장 시 저장자를 기록하고, 복원 시에도 잠금 번호를 올린다 - 회귀 테스트(PHPUnit·Vitest), 트러블슈팅 사례 32~34, 규정·API 문서, ja 언어팩, 편집기 번들 재빌드, 이력 문서 동반
This commit is contained in:
@@ -836,6 +836,19 @@ Laravel 은 `bootstrap/cache/packages.php` 가 있으면 stale 여부를 검사
|
||||
|
||||
> 상세: [pagination.md](docs/backend/pagination.md)
|
||||
|
||||
### 입력 크기에 비례해 커지는 메모리는 PHP 기본 한계 안에서 잰다
|
||||
|
||||
브라우저에서 잘 돌던 알고리즘·상수를 PHP 로 옮길 때 시간 상한(O(n·m) 가드)만 함께 오고 **메모리 상한은 오지 않는다.** PHP 배열은 원소당 수십 바이트라 (줄 수)² 표는 2,350줄에서 약 150MB 이고, 운영 서버의 기본 `memory_limit` 은 128M 이다. 개발 머신(512M)에서는 통과하고 서버에서만 500 이 되며, 예외는 `FatalError` 한 줄뿐이라 어느 요청의 어떤 입력이었는지 로그에 남지 않는다.
|
||||
|
||||
| ❌ 금지 | ✅ 올바른 사용 |
|
||||
|--------|---------------|
|
||||
| 카운트·길이만 쓰는 LCS/DP 에 전체 표 + backtrack | 두 행(또는 한 행) DP 로 길이만 구한다 — 추가 = 새 줄 − LCS, 삭제 = 옛 줄 − LCS 라 숫자가 같다 |
|
||||
| 브라우저 구현의 임계값(`DIFF_MAX_LINES` 등)을 그대로 이식하고 "가드가 있다" 고 간주 | 그 임계에서의 PHP 메모리를 실측하고 128M 아래인지 확인 — 임계가 시간 축만 막는 경우가 있다 |
|
||||
| "인접 버전 비교는 변경 영역이 작다" 는 가정으로 최악 경로를 비워 둠 | 변경 영역은 양끝이 동시에 바뀌면 파일 전체다 — 편집기가 `comment` 키를 떼어내는 첫 저장이 정확히 그 형태 |
|
||||
| 메모리 회귀 테스트를 "통과했다" 로만 잠금 | `memory_get_peak_usage()` 증가량 상한을 단언하고, 수정 전 값(146MB)을 테스트 메시지에 남긴다 |
|
||||
|
||||
> 상세: [service-repository.md "입력 크기에 비례하는 메모리"](docs/backend/service-repository.md)
|
||||
|
||||
### 검색 인덱스 재생성(리인덱싱)
|
||||
|
||||
| ❌ 금지 | ✅ 올바른 사용 |
|
||||
|
||||
@@ -48,6 +48,12 @@
|
||||
- 화면 편집기에서 환경설정과 연결된 항목(예: 헤더 로고)을 무심코 조작하면 그 연결이 소리 없이 끊기던 문제를 수정했습니다. 이제 그런 항목은 연결된 값이라는 안내와 원래 값이 표시되고, 「직접 지정으로 바꾸기」를 눌러야 편집할 수 있으며 「되돌리기」로 언제든 원래 연결로 복구할 수 있습니다.
|
||||
- `vendor-bundle:build-all --check` 가 설치되지 않은 번들 확장을 「소스 경로 없음」으로 건너뛰어, 그 확장의 vendor 번들이 갱신되지 않은 상태가 점검을 통과하던 문제를 수정했습니다. 이제 설치 여부와 무관하게 배포 원본 기준으로 판정합니다.
|
||||
- 레이아웃 편집기의 헤더 「로고 이미지」에서 파일을 올린 뒤 저장하면 「HTTPS 프로토콜 URL은 허용되지 않습니다」로 거부되던 문제를 수정했습니다. 업로드한 이미지의 주소가 사이트 전체 주소 형태로 발급되어 레이아웃 저장의 외부 주소 차단에 걸리고 있었으며, 배경 이미지는 검사 대상이 아니라 증상이 없었습니다. 이제 업로드 주소는 사이트 상대 경로로 발급되고, 사이트 자신의 주소와 「공개 자산 스토리지」로 지정한 저장소의 주소는 외부로 취급하지 않습니다.
|
||||
- 큰 공통 레이아웃(예: 사용자 화면 공통 레이아웃)을 레이아웃 편집기에서 처음 저장할 때 「저장 중 네트워크 오류가 발생했습니다 / Server Error」로 끝나던 문제를 수정했습니다. 버전 이력의 변경량(+N/-N 줄)을 계산하는 과정이 레이아웃 크기의 제곱에 비례하는 메모리를 써서 PHP 기본 메모리 한도(128MB)인 서버에서 실패했습니다. 이제 메모리가 레이아웃 크기에 비례하며 표시되는 변경량 숫자는 그대로입니다.
|
||||
- 레이아웃 편집기의 「확장 편집」 모드에서 저장하면 그 확장이 화면에 끼워 넣던 요소(예: 헤더의 통화 선택기)가 사라지던 문제를 수정했습니다. 내용을 바꾸지 않고 저장해도 일어났고 오류나 안내가 없어 사이트에서 그 요소가 없어진 뒤에야 알 수 있었습니다. 이제 끼워 넣은 요소가 그대로 보존되며, 되돌릴 자리를 확인할 수 없는 요소가 있으면 저장을 막고 그 사실을 안내합니다. 이미 사라진 사이트는 「버전 기록」에서 직전 버전을 복원하면 됩니다.
|
||||
- 레이아웃 편집기에서 저장이나 복원을 두 번 이상 한 뒤 「초기화」나 충돌 안내의 「최신 불러오기」를 누르면 최신이 아닌 옛 내용이 올라오던 문제를 수정했습니다. 그 화면을 다시 저장하면 최신 내용을 옛 내용이 덮을 수 있었고, 충돌 안내 뒤에는 새로고침 전까지 저장이 계속 거부됐습니다.
|
||||
- 레이아웃 편집기의 충돌 안내가 「최신 버전: -1」 로 표시되던 문제를 수정했습니다. 이제 실제 최신 버전 번호가 표시됩니다.
|
||||
- 레이아웃·레이아웃 확장의 버전 기록에 저장자가 항상 「알 수 없음」으로 표시되던 문제를 수정했습니다. 이제 저장한 관리자가 기록됩니다.
|
||||
- 버전 기록에서 복원한 직후, 복원 전 화면을 열어 둔 다른 관리자가 저장하면 복원 결과가 충돌 안내 없이 덮이던 문제를 수정했습니다. 이제 복원도 저장과 같이 충돌 검사의 기준 번호를 올립니다.
|
||||
|
||||
## [7.0.10] - 2026-09-06
|
||||
|
||||
|
||||
@@ -14,9 +14,10 @@ interface LayoutExtensionVersionRepositoryInterface
|
||||
* @param int $extensionId 레이아웃 확장 ID
|
||||
* @param array $oldContent 이전 콘텐츠
|
||||
* @param array|null $newContent 새 콘텐츠 (null이면 현재 확장 content 사용)
|
||||
* @param int|null $createdBy 저장자 ID (null 이면 현재 인증 사용자)
|
||||
* @return TemplateLayoutExtensionVersion 생성된 버전 모델
|
||||
*/
|
||||
public function saveVersion(int $extensionId, array $oldContent, ?array $newContent = null): TemplateLayoutExtensionVersion;
|
||||
public function saveVersion(int $extensionId, array $oldContent, ?array $newContent = null, ?int $createdBy = null): TemplateLayoutExtensionVersion;
|
||||
|
||||
/**
|
||||
* 특정 확장의 모든 버전 조회 (최신순)
|
||||
|
||||
@@ -14,9 +14,10 @@ interface LayoutVersionRepositoryInterface
|
||||
* @param int $layoutId 레이아웃 ID
|
||||
* @param array $oldContent 이전 콘텐츠
|
||||
* @param array|null $newContent 새 콘텐츠 (null이면 현재 레이아웃 content 사용)
|
||||
* @param int|null $createdBy 저장자 ID (null 이면 현재 인증 사용자)
|
||||
* @return TemplateLayoutVersion 생성된 버전 모델
|
||||
*/
|
||||
public function saveVersion(int $layoutId, array $oldContent, ?array $newContent = null): TemplateLayoutVersion;
|
||||
public function saveVersion(int $layoutId, array $oldContent, ?array $newContent = null, ?int $createdBy = null): TemplateLayoutVersion;
|
||||
|
||||
/**
|
||||
* 특정 레이아웃의 모든 버전 조회 (최신순)
|
||||
|
||||
@@ -63,20 +63,18 @@ class PublicLayoutController extends PublicBaseController
|
||||
}
|
||||
|
||||
try {
|
||||
// 캐시 버전을 키에 포함하여 모듈/플러그인 변경 시 캐시 무효화.
|
||||
// 서버 캐시 키는 **서버 현재** 확장 캐시 버전으로만 조립한다. 클라이언트 `?v=` 는 브라우저
|
||||
// HTTP 캐시 우회용 좌표일 뿐 서버 키의 근거가 아니다.
|
||||
//
|
||||
// 서버 캐시 키에는 **정수 버전만** 쓴다(소수 nonce 제거). 레이아웃 편집기는 같은 세션의
|
||||
// 저장·버전 복원 직후 브라우저 HTTP 캐시를 우회하려고 `?v={cacheVersion}.{nonce}` 형식으로
|
||||
// 요청한다(클라이언트 cache-bust nonce). 그런데 `serve` 가 이 문자열을 그대로 캐시 키에
|
||||
// 쓰면 키가 `...v{cacheVersion}.{nonce}.meta` 가 되는데, 저장 경로
|
||||
// `LayoutService::clearPublicServingCache` 는 `(int) ext.cache_version` 으로 nonce 없는
|
||||
// 키만 forget 하므로 키 형식이 어긋나 무효화가 빗나간다(저장/복원 후 편집기 캔버스만
|
||||
// stale). nonce 는 브라우저 HTTP 캐시 우회용(URL·ETag 차이로 이미 달성)이고, 서버 캐시 키
|
||||
// 정합은 정수 버전이 SSoT 다. `(int)` 캐스팅은 PHP 가 소수점에서 절단해 정수부만 남긴다.
|
||||
// `?v` 생략 시 현재 버전으로 폴백 — 리터럴 0 폴백은 워밍/무효화 어느 경로에도
|
||||
// 걸리지 않는 `.v0` 영구 사각 키를 만든다 (#588).
|
||||
$rawVersion = request()->query('v');
|
||||
$cacheVersion = $rawVersion !== null ? (int) $rawVersion : self::getExtensionCacheVersion();
|
||||
// 종전엔 `?v` 의 정수부를 키에 썼다(#588 — nonce 제거). 그런데 레이아웃 편집기는 부팅
|
||||
// 시점 `window.G7Config.cache_version` 에 nonce 만 붙여 계속 요청하고, 저장·복원은
|
||||
// `ext.cache_version` 을 `time()` 으로 올리며 `clearPublicServingCache` 는 **현재** 버전
|
||||
// 키만 지운다. 그래서 두 번째 bump 부터 부팅 버전 키가 영영 지워지지 않아 초기화·복원·
|
||||
// 409 「최신 불러오기」가 옛 content 를 받았고(실측: 초기화 직후 lock 4 응답, DB 는 lock 7),
|
||||
// 그 화면을 다시 저장하면 옛 내용이 최신을 덮을 수 있었다. 서버 버전으로 키를 고정하면
|
||||
// 무효화(현재 버전 키 forget)와 굽기(현재 버전 키 remember)가 같은 키를 본다. `?v` 가 어떤
|
||||
// 값이든 결과는 같고, 이전 버전 키는 bump 로 자연 이탈한다(TTL 만료).
|
||||
$cacheVersion = self::getExtensionCacheVersion();
|
||||
|
||||
// 편집기 출처 메타 옵션
|
||||
// - 옵션이 truthy 면 각 노드에 `__source` 메타를 부여한 응답을 반환
|
||||
|
||||
@@ -135,10 +135,17 @@ trait CalculatesJsonContentDiff
|
||||
/**
|
||||
* 라인 배열 두 개의 LCS diff — 추가/삭제 라인 "수"를 반환한다.
|
||||
*
|
||||
* 공통 prefix/suffix 를 먼저 트리밍해 LCS DP 입력을 변경 영역으로 축소한다(큰
|
||||
* content 의 작은 변경도 빠르게 처리). 라인 원문은 누적하지 않고 카운트만 세므로
|
||||
* 메모리도 절약된다(저장 대상은 카운트뿐). 프론트 computeLineDiff 와 동일 전략이라
|
||||
* added/removed 카운트가 일치한다.
|
||||
* 공통 prefix/suffix 를 먼저 트리밍해 LCS 입력을 변경 영역으로 축소한다(큰 content 의
|
||||
* 작은 변경도 빠르게 처리). 카운트는 LCS **길이**만으로 결정된다 — 추가 = 새 줄 수 − LCS,
|
||||
* 삭제 = 옛 줄 수 − LCS — 이므로 표 전체를 만들어 되짚을 필요가 없고, 두 행만 쓰는
|
||||
* 길이 계산으로 메모리를 변경 영역 줄 수에 비례하게 묶는다. 프론트 computeLineDiff 는
|
||||
* 실제 diff 줄을 그려야 해서 전체 표를 쓰지만 같은 LCS 규칙이므로 added/removed 카운트가
|
||||
* 일치한다.
|
||||
*
|
||||
* 트리밍은 양끝이 동시에 바뀌면 무력하다 — 편집기는 저장 시 `comment` 키를 떼어내므로
|
||||
* 큰 공통 레이아웃의 첫 편집기 저장은 변경 영역이 파일 전체(2,000줄 이상)가 된다.
|
||||
* 종전의 (줄 수)² PHP 배열은 그 경우 약 150MB 를 써서 PHP 기본 memory_limit(128M)
|
||||
* 서버에서 저장이 500 으로 끝났다(개발 머신은 512M 이라 드러나지 않았다).
|
||||
*
|
||||
* @param array<string> $a 이전 라인
|
||||
* @param array<string> $b 새 라인
|
||||
@@ -172,43 +179,50 @@ trait CalculatesJsonContentDiff
|
||||
$nb = count($midB);
|
||||
|
||||
// 안전 가드 — 변경 영역이 과대하면 LCS 를 생략하고 라인 집합 차집합으로 근사한다.
|
||||
// 프론트 lineDiff.ts 의 DIFF_MAX_LINES(4000) 와 동일 임계. 인접 버전 비교는 변경
|
||||
// 영역이 작아 이 경로를 타지 않으며, 비정상적으로 큰 변경에서만 O(n·m) DP 를 회피한다.
|
||||
// 프론트 lineDiff.ts 의 DIFF_MAX_LINES(4000) 와 동일 임계. 시간 O(n·m) 의 상한이며,
|
||||
// 아래 길이 계산은 메모리가 두 행뿐이라 이 임계 안에서는 memory_limit 과 무관하다.
|
||||
if ($na > self::DIFF_MAX_LINES || $nb > self::DIFF_MAX_LINES) {
|
||||
return $this->approximateLineCounts($midA, $midB);
|
||||
}
|
||||
|
||||
// LCS DP
|
||||
$dp = array_fill(0, $na + 1, array_fill(0, $nb + 1, 0));
|
||||
$lcs = $this->lcsLength($midA, $midB);
|
||||
|
||||
return [$nb - $lcs, $na - $lcs];
|
||||
}
|
||||
|
||||
/**
|
||||
* 두 라인 배열의 LCS(최장 공통 부분수열) 길이 — 두 행만 쓰는 DP.
|
||||
*
|
||||
* 전체 표(종전 `$dp[$i][$j]`)와 같은 점화식(`a[i] === b[j] ? next[j+1] + 1 : max(next[j],
|
||||
* cur[j+1])`)을 행 단위로 굴려 마지막 행의 첫 칸만 남긴다. 되짚기(backtrack)가 필요한
|
||||
* 프론트 diff 뷰와 달리 여기서는 길이만 쓰므로 카운트가 종전과 정확히 같다.
|
||||
*
|
||||
* @param array<int, string> $a 이전 라인 (0 부터 연속 인덱스)
|
||||
* @param array<int, string> $b 새 라인 (0 부터 연속 인덱스)
|
||||
* @return int LCS 길이
|
||||
*/
|
||||
private function lcsLength(array $a, array $b): int
|
||||
{
|
||||
$na = count($a);
|
||||
$nb = count($b);
|
||||
|
||||
if ($na === 0 || $nb === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$next = array_fill(0, $nb + 1, 0);
|
||||
for ($i = $na - 1; $i >= 0; $i--) {
|
||||
$cur = array_fill(0, $nb + 1, 0);
|
||||
$line = $a[$i];
|
||||
for ($j = $nb - 1; $j >= 0; $j--) {
|
||||
$dp[$i][$j] = $midA[$i] === $midB[$j]
|
||||
? $dp[$i + 1][$j + 1] + 1
|
||||
: max($dp[$i + 1][$j], $dp[$i][$j + 1]);
|
||||
$cur[$j] = $line === $b[$j]
|
||||
? $next[$j + 1] + 1
|
||||
: max($next[$j], $cur[$j + 1]);
|
||||
}
|
||||
$next = $cur;
|
||||
}
|
||||
|
||||
// backtrack — 삭제/추가 라인 수만 카운트
|
||||
$added = 0;
|
||||
$removed = 0;
|
||||
$i = 0;
|
||||
$j = 0;
|
||||
while ($i < $na && $j < $nb) {
|
||||
if ($midA[$i] === $midB[$j]) {
|
||||
$i++;
|
||||
$j++;
|
||||
} elseif ($dp[$i + 1][$j] >= $dp[$i][$j + 1]) {
|
||||
$removed++;
|
||||
$i++;
|
||||
} else {
|
||||
$added++;
|
||||
$j++;
|
||||
}
|
||||
}
|
||||
$removed += $na - $i;
|
||||
$added += $nb - $j;
|
||||
|
||||
return [$added, $removed];
|
||||
return $next[0];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -8,6 +8,7 @@ use App\Models\TemplateLayoutExtensionVersion;
|
||||
use App\Repositories\Concerns\CalculatesJsonContentDiff;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Database\Eloquent\ModelNotFoundException;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class LayoutExtensionVersionRepository implements LayoutExtensionVersionRepositoryInterface
|
||||
@@ -28,9 +29,10 @@ class LayoutExtensionVersionRepository implements LayoutExtensionVersionReposito
|
||||
* @param int $extensionId 레이아웃 확장 ID
|
||||
* @param array $content 저장할 content 스냅샷
|
||||
* @param array|null $previousContent 직전 버전 content (변경 요약 기준). null 이면 변경 요약 0
|
||||
* @param int|null $createdBy 저장자 ID (null 이면 현재 인증 사용자)
|
||||
* @return TemplateLayoutExtensionVersion 생성된 버전
|
||||
*/
|
||||
public function saveVersion(int $extensionId, array $content, ?array $previousContent = null): TemplateLayoutExtensionVersion
|
||||
public function saveVersion(int $extensionId, array $content, ?array $previousContent = null, ?int $createdBy = null): TemplateLayoutExtensionVersion
|
||||
{
|
||||
$nextVersion = $this->getNextVersion($extensionId);
|
||||
|
||||
@@ -44,6 +46,8 @@ class LayoutExtensionVersionRepository implements LayoutExtensionVersionReposito
|
||||
'version' => $nextVersion,
|
||||
'content' => $content,
|
||||
'changes_summary' => $changesSummary,
|
||||
// 저장자 — 버전 목록의 「저장자」 표시 근거. 종전엔 기록하지 않아 항상 「알 수 없음」이었다.
|
||||
'created_by' => $createdBy ?? Auth::id(),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -165,9 +169,10 @@ class LayoutExtensionVersionRepository implements LayoutExtensionVersionReposito
|
||||
$extension = LayoutExtension::findOrFail($extensionId);
|
||||
$currentContent = $extension->content;
|
||||
|
||||
// 3. 확장을 복원할 content로 업데이트
|
||||
// 3. 확장을 복원할 content로 업데이트 — lock_version 도 올린다(레이아웃 본체와 동형).
|
||||
$extension->update([
|
||||
'content' => $versionToRestore->content,
|
||||
'lock_version' => ((int) ($extension->lock_version ?? 0)) + 1,
|
||||
]);
|
||||
|
||||
// 4. 복원 결과를 새 버전으로 저장 — content 는 복원된 내용, changes_summary 는 복원
|
||||
|
||||
@@ -8,6 +8,7 @@ use App\Models\TemplateLayoutVersion;
|
||||
use App\Repositories\Concerns\CalculatesJsonContentDiff;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Database\Eloquent\ModelNotFoundException;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class LayoutVersionRepository implements LayoutVersionRepositoryInterface
|
||||
@@ -28,9 +29,10 @@ class LayoutVersionRepository implements LayoutVersionRepositoryInterface
|
||||
* @param int $layoutId 레이아웃 ID
|
||||
* @param array $content 저장할 content 스냅샷 (이 버전이 담는 내용)
|
||||
* @param array|null $previousContent 직전 버전 content (변경 요약 기준). null 이면 변경 요약 0
|
||||
* @param int|null $createdBy 저장자 ID (null 이면 현재 인증 사용자)
|
||||
* @return TemplateLayoutVersion 생성된 버전
|
||||
*/
|
||||
public function saveVersion(int $layoutId, array $content, ?array $previousContent = null): TemplateLayoutVersion
|
||||
public function saveVersion(int $layoutId, array $content, ?array $previousContent = null, ?int $createdBy = null): TemplateLayoutVersion
|
||||
{
|
||||
$nextVersion = $this->getNextVersion($layoutId);
|
||||
|
||||
@@ -44,6 +46,8 @@ class LayoutVersionRepository implements LayoutVersionRepositoryInterface
|
||||
'version' => $nextVersion,
|
||||
'content' => $content,
|
||||
'changes_summary' => $changesSummary,
|
||||
// 저장자 — 버전 목록의 「저장자」 표시 근거. 종전엔 기록하지 않아 항상 「알 수 없음」이었다.
|
||||
'created_by' => $createdBy ?? Auth::id(),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -109,9 +113,11 @@ class LayoutVersionRepository implements LayoutVersionRepositoryInterface
|
||||
$layout = TemplateLayout::findOrFail($layoutId);
|
||||
$currentContent = $layout->content;
|
||||
|
||||
// 3. 레이아웃을 복원할 content로 업데이트
|
||||
// 3. 레이아웃을 복원할 content로 업데이트 — lock_version 도 올린다. 복원 직전 화면을 열어 둔
|
||||
// 다른 편집기가 옛 lock 으로 저장하면 409 가 나야 복원 결과가 조용히 덮이지 않는다.
|
||||
$layout->update([
|
||||
'content' => $versionToRestore->content,
|
||||
'lock_version' => ((int) ($layout->lock_version ?? 0)) + 1,
|
||||
]);
|
||||
|
||||
// 4. 복원 결과를 새 버전으로 저장 — content 는 복원된 내용(versionToRestore),
|
||||
|
||||
@@ -292,13 +292,19 @@ class LayoutExtensionService
|
||||
* @param array $components 주입된 컴포넌트 배열
|
||||
* @param int $extensionId 확장 PK
|
||||
* @param LayoutExtension|null $extension 출처 라벨 부여용 확장 모델
|
||||
* @param int|null $injectionIndex overlay `injections[]` 순번 — 편집기가 저장 시 노드를 원래 injection 으로 되돌리는 열쇠. extension_point 주입은 null
|
||||
* @return array 메타가 부여된 컴포넌트 배열
|
||||
*
|
||||
* @since engine-v1.50.0
|
||||
*/
|
||||
private function markExtensionSource(array $components, int $extensionId, ?LayoutExtension $extension = null): array
|
||||
private function markExtensionSource(array $components, int $extensionId, ?LayoutExtension $extension = null, ?int $injectionIndex = null): array
|
||||
{
|
||||
return $this->applySourceMetaRecursively($components, $this->buildExtensionSourceMeta($extension, $extensionId));
|
||||
$meta = $this->buildExtensionSourceMeta($extension, $extensionId);
|
||||
if ($injectionIndex !== null) {
|
||||
$meta['injectionIndex'] = $injectionIndex;
|
||||
}
|
||||
|
||||
return $this->applySourceMetaRecursively($components, $meta);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -747,7 +753,11 @@ class LayoutExtensionService
|
||||
'injection_count' => count($injections),
|
||||
]);
|
||||
|
||||
foreach ($injections as $injection) {
|
||||
// injection 순번을 함께 순회한다 — 편집기 확장 편집 모드가 호스트 병합 트리에서 이 확장의
|
||||
// 노드를 추출해 `injections[].components` 로 되돌릴 때 어느 injection 인지 알 수 있어야
|
||||
// 한다. 순번이 메타에 없으면 재조립이 모든 노드를 버려 저장본의 injections 가 통째로
|
||||
// 비워진다(무변경 저장으로도 발생, 예외·경고 없음).
|
||||
foreach ($injections as $injectionIndex => $injection) {
|
||||
$targetId = $injection['target_id'] ?? null;
|
||||
$position = $injection['position'] ?? 'append_child';
|
||||
|
||||
@@ -785,7 +795,12 @@ class LayoutExtensionService
|
||||
// 편집 모드 출처 메타 부여 — 주입 노드와 그 자식 모두에 extension 메타
|
||||
// @since engine-v1.50.0
|
||||
if ($withSourceMeta) {
|
||||
$components = $this->markExtensionSource($components, $overlay->id, $overlay);
|
||||
$components = $this->markExtensionSource(
|
||||
$components,
|
||||
$overlay->id,
|
||||
$overlay,
|
||||
is_int($injectionIndex) ? $injectionIndex : null
|
||||
);
|
||||
}
|
||||
|
||||
$injected = $this->injectAtTarget(
|
||||
|
||||
@@ -345,7 +345,7 @@ _단건 응답: `data` 는 병합된 레이아웃 JSON 객체입니다 (상속
|
||||
| pageConfig / schema | object | `{}` | 플러그인 설정 레이아웃 전용 (설정 UI 안내/스키마) |
|
||||
| lock_version | integer | `3` | 낙관적 잠금 버전. `with_source_meta=1` 일 때만 부착 (편집기 저장 시 `expected_lock_version` 으로 되돌려 보냄) |
|
||||
| __editor | object | `{"original": { ... }}` | 자식 레이아웃의 저장 원본 content. `with_source_meta=1` 일 때만 부착 (편집기 전용) |
|
||||
| __source (각 노드 내부) | object | `{"kind":"base","layout":"_user_base"}` | 각 컴포넌트/데이터소스 노드의 출처 메타 (`base` / `extension` / `partial` / `route`). `with_source_meta=1` 일 때만 부착 |
|
||||
| __source (각 노드 내부) | object | `{"kind":"base","layout":"_user_base"}` | 각 컴포넌트/데이터소스 노드의 출처 메타 (`base` / `extension` / `partial` / `route`). `with_source_meta=1` 일 때만 부착. `extension` 출처가 overlay 주입이면 `injectionIndex`(그 확장 content 의 `injections[]` 순번)를 함께 실어 편집기가 저장 시 원래 injection 으로 되돌린다 |
|
||||
|
||||
응답 헤더: `ETag`(본문 md5), `Cache-Control: public, max-age=3600`, `Vary: Accept-Encoding, Accept-Language`. 클라이언트 `If-None-Match` 가 일치하면 본문 없이 `304 Not Modified` 를 반환합니다.
|
||||
|
||||
@@ -394,6 +394,6 @@ _단건 응답: `data` 는 병합된 레이아웃 JSON 객체입니다 (상속
|
||||
|
||||
<!-- @generated:end -->
|
||||
|
||||
**설명** 활성 템플릿의 병합된 레이아웃 JSON을 프론트엔드에 서빙합니다. 템플릿이 존재하고 활성 상태여야 하며, 상속 병합·확장 적용을 마친 결과를 ETag·Cache-Control 헤더와 함께 반환하고 미변경 시 304로 응답합니다. 레이아웃의 `permissions`에 따라 접근을 제한하고(비회원 401, 권한 부족 403), 컴포넌트 단위 권한 필터링을 사용자별로 적용합니다. 쿼리 `v`(정수 캐시 버전)로 캐시를 구분하며, `with_source_meta=1`은 `core.templates.layouts.edit` 권한이 있어야 노드별 출처 메타(편집기 전용)를 포함해 반환합니다.
|
||||
**설명** 활성 템플릿의 병합된 레이아웃 JSON을 프론트엔드에 서빙합니다. 템플릿이 존재하고 활성 상태여야 하며, 상속 병합·확장 적용을 마친 결과를 ETag·Cache-Control 헤더와 함께 반환하고 미변경 시 304로 응답합니다. 레이아웃의 `permissions`에 따라 접근을 제한하고(비회원 401, 권한 부족 403), 컴포넌트 단위 권한 필터링을 사용자별로 적용합니다. 쿼리 `v` 는 브라우저 HTTP 캐시 우회용 좌표이며(레이아웃 편집기는 `{cache_version}.{nonce}` 형식으로 보냄), 서버 캐시 키는 요청의 `v` 와 무관하게 서버 현재 확장 캐시 버전으로만 만들어져 저장·복원 직후의 재요청이 항상 최신 내용을 받습니다. `with_source_meta=1`은 `core.templates.layouts.edit` 권한이 있어야 노드별 출처 메타(편집기 전용)를 포함해 반환합니다.
|
||||
|
||||
|
||||
|
||||
@@ -1679,6 +1679,15 @@ $data['depth'] = ($parent->depth ?? 0) + 1;
|
||||
|
||||
---
|
||||
|
||||
## 입력 크기에 비례하는 메모리
|
||||
|
||||
브라우저에서 잘 돌던 알고리즘을 PHP 로 옮길 때 시간 상한만 함께 오고 메모리 상한은 오지 않는다. PHP 배열은 원소당 수십 바이트라 (줄 수)² 크기 표는 2,350줄에서 약 150MB 이고, 운영 서버의 PHP 기본 `memory_limit` 은 128M 이다. 개발 머신에서는 통과하고 서버에서만 500 이 되며, 남는 것은 `Allowed memory size … exhausted` 한 줄뿐이다.
|
||||
|
||||
- 카운트·길이만 쓰는 LCS/DP 는 두 행 DP 로 길이만 구한다. 추가 = 새 줄 − LCS, 삭제 = 옛 줄 − LCS 이므로 전체 표를 되짚어 얻는 숫자와 같다. 실제 줄을 그려야 하는 쪽(브라우저 diff 뷰)만 전체 표를 쓴다.
|
||||
- 브라우저 구현의 임계값을 옮겨 왔다면 그 임계에서의 PHP 메모리를 실측한다. 시간만 막는 임계가 있다.
|
||||
- "인접 버전 비교는 변경 영역이 작다" 같은 가정으로 최악 경로를 비워 두지 않는다. 앞뒤 공통 부분 트리밍은 양끝이 동시에 바뀌면 무력하고, 편집기가 `comment` 키를 떼어내는 첫 저장이 정확히 그 형태다.
|
||||
- 메모리 회귀 테스트는 `memory_get_peak_usage()` 증가량의 상한을 단언한다. 잠금 대상은 코어 `CalculatesJsonContentDiff`(레이아웃·레이아웃 확장 버전 변경량)이며 `tests/Unit/Repositories/Concerns/CalculatesJsonContentDiffTest.php` 가 상한과 참조 구현 동치를 함께 고정한다.
|
||||
|
||||
## 관련 문서
|
||||
|
||||
- [컨트롤러 계층 구조](controllers.md) - Controller에서 Service 사용
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
- 레이아웃 편집기 [화면 동작] 탭의 「인증번호 확인」·「인증번호 다시 받기」 항목 이름의 일본어 번역을 추가했습니다.
|
||||
- 레이아웃 편집기에서 환경설정과 연결된 항목에 표시되는 안내와 「직접 지정으로 바꾸기」·「데이터 연결로 되돌리기」 버튼의 일본어 번역을 추가했습니다.
|
||||
- 레이아웃 편집기 숫자 입력 항목의 입력 자리 표시 문구의 일본어 번역을 추가했습니다.
|
||||
- 레이아웃 편집기 「확장 편집」 저장이 끼워 넣은 요소를 되돌릴 수 없어 막혔을 때 표시되는 안내의 일본어 번역을 추가했습니다.
|
||||
|
||||
## [1.0.9] - 2026-09-06
|
||||
|
||||
|
||||
@@ -299,6 +299,8 @@
|
||||
"blocked_inactive_extension": "無効化された拡張コンポーネントが新たに追加されているため保存できません",
|
||||
"network_error_title": "保存中にネットワークエラーが発生しました",
|
||||
"guard_no_document": "保存するレイアウトドキュメントが読み込まれていません",
|
||||
"guard_extension_reassembly_title": "拡張の内容を元の位置に戻せないため保存しませんでした",
|
||||
"guard_extension_reassembly": "この拡張が差し込んだ要素 {count} 件が、どの位置から来たのか確認できません。このまま保存するとその要素が消えるため、保存を止めました。ページを再読み込みしてからもう一度お試しください。",
|
||||
"concurrent": {
|
||||
"title": "別のユーザーが先に保存しました",
|
||||
"message": "このレイアウトは別の管理者が先に保存しました。最新版を読み込みますか?",
|
||||
|
||||
@@ -320,6 +320,8 @@
|
||||
"blocked_inactive_extension": "Cannot save — newly added components belong to a deactivated extension",
|
||||
"network_error_title": "Network error while saving",
|
||||
"guard_no_document": "No layout document loaded — nothing to save",
|
||||
"guard_extension_reassembly_title": "Not saved — extension content could not be mapped back",
|
||||
"guard_extension_reassembly": "{count} element(s) injected by this extension could not be traced to their original slot. Saving would drop them, so the save was blocked. Reload the page and try again.",
|
||||
"concurrent": {
|
||||
"title": "Another user saved first",
|
||||
"message": "This layout was saved by another administrator. Load the latest version?",
|
||||
|
||||
@@ -320,6 +320,8 @@
|
||||
"blocked_inactive_extension": "비활성화된 확장 컴포넌트가 새로 추가되어 저장할 수 없습니다",
|
||||
"network_error_title": "저장 중 네트워크 오류가 발생했습니다",
|
||||
"guard_no_document": "저장할 레이아웃 문서가 로드되지 않았습니다",
|
||||
"guard_extension_reassembly_title": "확장 내용을 되돌릴 수 없어 저장하지 않았습니다",
|
||||
"guard_extension_reassembly": "이 확장이 끼워 넣은 요소 {count}개가 어느 자리에서 왔는지 확인되지 않습니다. 그대로 저장하면 그 요소가 사라지므로 저장을 막았습니다. 페이지를 새로고침한 뒤 다시 시도해 주세요.",
|
||||
"concurrent": {
|
||||
"title": "다른 사용자가 먼저 저장했습니다",
|
||||
"message": "이 레이아웃은 다른 관리자가 먼저 저장했습니다. 최신 버전을 불러오시겠습니까?",
|
||||
|
||||
+6
-6
File diff suppressed because one or more lines are too long
@@ -5,6 +5,21 @@
|
||||
>
|
||||
> 형식: [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)
|
||||
|
||||
## [engine-v1.66.1] - 2026-09-09
|
||||
|
||||
### Fixed
|
||||
|
||||
#### 확장 편집 모드 저장 — overlay injections 보존
|
||||
- 호스트 병합 모드에서 추출한 확장 노드에 `__injectionIndex` 가 없어 `reassembleContent` 가 전부 버리고 `injections[].components: []` 를 PUT 하던 결함 수정 — 백엔드가 `__source.injectionIndex` 로 실어 주는 순번을 읽고, 없으면 원본 injection 의 노드 id 로 되돌린다 (useExtensionDocument.ts `reassembleOverlayContent`)
|
||||
- 되돌리지 못한 노드가 있고 원본에 잃을 컴포넌트가 있으면 PUT 하지 않고 `guard_extension_reassembly` 를 돌려준다 — `SaveFeedbackBanner` 가 자동 dismiss 없는 오류 배너로 표시 (`g7le-save-banner-guard-extension-reassembly`)
|
||||
- `NodeSource.injectionIndex` 타입 추가 (layoutTreeUtils.ts)
|
||||
|
||||
#### 409 배너 버전 표기
|
||||
- 서버(`ResponseHelper::error`)가 `errors` 아래에 싣는 `current_version`/`your_version` 을 읽지 못해 「최신 버전: -1」 로 표시되던 결함 수정 — `utils/conflictVersion.ts` `readConflictVersion` 단일 판독(`errors.{key}` 우선, 최상위 폴백)을 레이아웃 저장·확장 저장·inject_props 교차 저장 세 경로가 공유
|
||||
|
||||
### Notes
|
||||
- 편집기 재로드 stale(부팅 시점 `cache_version` 키)은 서버 `PublicLayoutController::serve` 캐시 키를 서버 현재 버전으로 고정해 해소 — 클라이언트 nonce 규약(`?v={cacheVersion}.{nonce}`)은 그대로다
|
||||
|
||||
## [engine-v1.66.0] - 2026-09-08
|
||||
|
||||
### Added
|
||||
|
||||
+145
@@ -681,3 +681,148 @@ describe('useExtensionDocument — 로드/저장', () => {
|
||||
expect(result.current.document.needsHostPicker).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// [case:backend-34] overlay 확장 — 호스트 병합 모드 저장이 injections 를 보존해야 한다
|
||||
// ============================================================================
|
||||
describe('[case:backend-34] overlay 확장 호스트 병합 저장 — injections 보존', () => {
|
||||
let fetchSpy: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
fetchSpy = vi.fn();
|
||||
(global as any).fetch = fetchSpy;
|
||||
if (typeof window !== 'undefined') window.localStorage?.clear();
|
||||
});
|
||||
|
||||
function enterExtension(result: any, extId: string) {
|
||||
act(() => {
|
||||
result.current.editor.dispatch({ type: 'ENTER_EXTENSION_EDIT', extensionId: extId });
|
||||
});
|
||||
}
|
||||
|
||||
const overlayContent = {
|
||||
target_layout: '_user_base',
|
||||
injections: [
|
||||
{ target_id: 'anchor', position: 'append_child', components: [{ id: 'ext-sel', name: 'Div' }] },
|
||||
],
|
||||
priority: 320,
|
||||
};
|
||||
|
||||
/** 확장 GET + 호스트 GET(with_source_meta) mock — 호스트 앵커 아래 주입 노드 1개 */
|
||||
function mockOverlay(hostChild: Record<string, unknown>) {
|
||||
fetchSpy.mockImplementation((url: string) => {
|
||||
if (url.includes('/layout-extensions/')) {
|
||||
return Promise.resolve(
|
||||
extResponse({
|
||||
id: 7,
|
||||
extension_type: 'overlay',
|
||||
lock_version: 0,
|
||||
content: JSON.stringify(overlayContent),
|
||||
host_layouts: ['_user_base'],
|
||||
}),
|
||||
);
|
||||
}
|
||||
return Promise.resolve(
|
||||
hostResponse([{ id: 'anchor', name: 'Div', __source: { kind: 'base' }, children: [hostChild] }]),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
it('reassembleContent — __injectionIndex 없이 백엔드 __source.injectionIndex 만 있어도 원래 injection 으로 분배', () => {
|
||||
const roots = [
|
||||
{ id: 'ext-sel', name: 'Div', __source: { kind: 'extension', extensionId: 7, injectionIndex: 0 } },
|
||||
] as any;
|
||||
const out = reassembleContent(overlayContent as any, roots) as any;
|
||||
expect(out.injections[0].components).toEqual([{ id: 'ext-sel', name: 'Div' }]);
|
||||
});
|
||||
|
||||
it('호스트 병합 모드 무변경 저장 — 주입 컴포넌트가 그대로 PUT 된다(비워지지 않음)', async () => {
|
||||
mockOverlay({
|
||||
id: 'ext-sel',
|
||||
name: 'Div',
|
||||
__source: { kind: 'extension', extensionId: 7, injectionIndex: 0 },
|
||||
});
|
||||
const { result } = renderHook(() => combinedHook(), { wrapper: makeWrapper() });
|
||||
enterExtension(result, '7');
|
||||
await waitFor(() => expect(result.current.document.document).not.toBeNull());
|
||||
expect(result.current.document.document!.editability).toBe('ok');
|
||||
|
||||
fetchSpy.mockImplementationOnce(() =>
|
||||
Promise.resolve({ ok: true, status: 200, json: async () => ({ data: { lock_version: 1 } }) }),
|
||||
);
|
||||
let saveResult: any;
|
||||
await act(async () => {
|
||||
saveResult = await result.current.document.save();
|
||||
});
|
||||
expect(saveResult.kind).toBe('success');
|
||||
const putCall = fetchSpy.mock.calls.find((c) => c[1]?.method === 'PUT')!;
|
||||
const body = JSON.parse(putCall[1].body);
|
||||
// 종전: injections[0].components 가 [] 로 비워져 PUT — 사이트에서 그 확장 UI 가 조용히 사라졌다.
|
||||
expect(body.content.injections[0].components).toEqual([{ id: 'ext-sel', name: 'Div' }]);
|
||||
expect(body.content.priority).toBe(320);
|
||||
expect(body.content.target_layout).toBe('_user_base');
|
||||
});
|
||||
|
||||
it('injection 을 되찾을 수 없는 노드가 있으면 PUT 하지 않고 guard 를 돌려준다(손실 차단)', async () => {
|
||||
// 순번 메타도 없고 id 도 원본 injection 과 맞지 않는 노드 — 어느 injection 인지 알 수 없다.
|
||||
mockOverlay({ id: 'unknown-node', name: 'Div', __source: { kind: 'extension', extensionId: 7 } });
|
||||
const { result } = renderHook(() => combinedHook(), { wrapper: makeWrapper() });
|
||||
enterExtension(result, '7');
|
||||
await waitFor(() => expect(result.current.document.document).not.toBeNull());
|
||||
|
||||
let saveResult: any;
|
||||
await act(async () => {
|
||||
saveResult = await result.current.document.save();
|
||||
});
|
||||
expect(saveResult.kind).toBe('guard_extension_reassembly');
|
||||
expect(fetchSpy.mock.calls.find((c) => c[1]?.method === 'PUT')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('순번 메타가 없어도 id 가 원본 injection 의 노드와 맞으면 그 injection 으로 되돌린다(구 백엔드 호환)', async () => {
|
||||
mockOverlay({ id: 'ext-sel', name: 'Div', __source: { kind: 'extension', extensionId: 7 } });
|
||||
const { result } = renderHook(() => combinedHook(), { wrapper: makeWrapper() });
|
||||
enterExtension(result, '7');
|
||||
await waitFor(() => expect(result.current.document.document).not.toBeNull());
|
||||
fetchSpy.mockImplementationOnce(() =>
|
||||
Promise.resolve({ ok: true, status: 200, json: async () => ({ data: { lock_version: 1 } }) }),
|
||||
);
|
||||
let saveResult: any;
|
||||
await act(async () => {
|
||||
saveResult = await result.current.document.save();
|
||||
});
|
||||
expect(saveResult.kind).toBe('success');
|
||||
const putCall = fetchSpy.mock.calls.find((c) => c[1]?.method === 'PUT')!;
|
||||
expect(JSON.parse(putCall[1].body).content.injections[0].components).toEqual([
|
||||
{ id: 'ext-sel', name: 'Div' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('409 — 서버가 errors 아래에 넣는 current_version/your_version 을 읽는다(종전 -1)', async () => {
|
||||
fetchSpy.mockResolvedValue(
|
||||
extResponse({
|
||||
id: 1,
|
||||
extension_type: 'extension_point',
|
||||
lock_version: 3,
|
||||
content: JSON.stringify({ components: [{ id: 'root' }] }),
|
||||
}),
|
||||
);
|
||||
const { result } = renderHook(() => combinedHook(), { wrapper: makeWrapper() });
|
||||
enterExtension(result, '1');
|
||||
await waitFor(() => expect(result.current.document.document).not.toBeNull());
|
||||
|
||||
fetchSpy.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 409,
|
||||
json: async () => ({
|
||||
success: false,
|
||||
message: '다른 사용자가 먼저 저장했습니다.',
|
||||
errors: { error: 'concurrent_modification', current_version: 5, your_version: 3 },
|
||||
}),
|
||||
});
|
||||
let saveResult: any;
|
||||
await act(async () => {
|
||||
saveResult = await result.current.document.save();
|
||||
});
|
||||
expect(saveResult).toEqual({ kind: 'concurrent_modification', currentVersion: 5, yourVersion: 3 });
|
||||
});
|
||||
});
|
||||
|
||||
+33
@@ -319,3 +319,36 @@ describe('useLayoutDocument — 중첩 경로 layoutName PUT URL 슬래시 보
|
||||
expect(putUrl).not.toContain('auth%2Fforgot_password');
|
||||
});
|
||||
});
|
||||
|
||||
describe('useLayoutDocument — 409 본문이 errors 아래에 버전을 담는 실제 서버 형식', () => {
|
||||
beforeEach(() => {
|
||||
const fetchMock = vi.fn();
|
||||
fetchMock.mockResolvedValueOnce(initialLoadResponse as any).mockResolvedValueOnce(
|
||||
mockStatus(409, {
|
||||
success: false,
|
||||
message: '다른 사용자가 먼저 저장했습니다. (리소스: template_layouts:1, 현재 버전: 9, 보낸 버전: 7)',
|
||||
errors: { error: 'concurrent_modification', current_version: 9, your_version: 7 },
|
||||
})
|
||||
);
|
||||
(globalThis as any).fetch = fetchMock;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete (globalThis as any).fetch;
|
||||
});
|
||||
|
||||
it('ResponseHelper 형식(errors.current_version) 에서도 최신/내 버전을 읽는다 — 종전 배너 「최신 버전: -1」', async () => {
|
||||
const { result } = renderHook(() => useLayoutDocument(), { wrapper });
|
||||
await waitFor(() => expect(result.current.document).not.toBeNull());
|
||||
|
||||
let saveResult: any;
|
||||
await act(async () => {
|
||||
saveResult = await result.current.save();
|
||||
});
|
||||
expect(saveResult).toEqual({
|
||||
kind: 'concurrent_modification',
|
||||
currentVersion: 9,
|
||||
yourVersion: 7,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+12
@@ -74,4 +74,16 @@ describe('saveInjectedPropsToExtension', () => {
|
||||
const result = await saveInjectedPropsToExtension(tpl, 99, 'h', {}, fetchMock as any);
|
||||
expect(result.kind).toBe('not_found');
|
||||
});
|
||||
it('409 본문이 errors 아래에 버전을 담아도(ResponseHelper 형식) conflict 버전을 읽는다', async () => {
|
||||
const content = { injections: [{ target_id: 'h', position: 'inject_props', props: {} }] };
|
||||
const fetchMock = vi.fn();
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
jsonRes(200, { data: { id: 2, content: JSON.stringify(content), lock_version: 3 } }),
|
||||
);
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
jsonRes(409, { success: false, errors: { current_version: 7, your_version: 3 } }, false),
|
||||
);
|
||||
const result = await saveInjectedPropsToExtension(tpl, 2, 'h', { a: 1 }, fetchMock as any);
|
||||
expect(result).toEqual({ kind: 'conflict', currentVersion: 7, yourVersion: 3 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -38,7 +38,12 @@ export function SaveFeedbackBanner(props: SaveFeedbackBannerProps): React.ReactE
|
||||
// 는 5초 후 자동 dismiss. concurrent / validation 은 사용자가 명시적으로 닫음.
|
||||
useEffect(() => {
|
||||
if (!result) return;
|
||||
if (result.kind === 'concurrent_modification' || result.kind === 'validation_failed') return;
|
||||
if (
|
||||
result.kind === 'concurrent_modification' ||
|
||||
result.kind === 'validation_failed' ||
|
||||
result.kind === 'guard_extension_reassembly'
|
||||
)
|
||||
return;
|
||||
const timer = setTimeout(onDismiss, 5000);
|
||||
return () => clearTimeout(timer);
|
||||
}, [result, onDismiss]);
|
||||
@@ -98,6 +103,22 @@ export function SaveFeedbackBanner(props: SaveFeedbackBannerProps): React.ReactE
|
||||
);
|
||||
}
|
||||
|
||||
if (result.kind === 'guard_extension_reassembly') {
|
||||
// 확장 편집 모드 저장 가드 — 자동 dismiss 없음(저장이 안 됐다는 사실을 운영자가 봐야 한다).
|
||||
return (
|
||||
<BannerShell
|
||||
tone="error"
|
||||
testId="g7le-save-banner-guard-extension-reassembly"
|
||||
onDismiss={onDismiss}
|
||||
>
|
||||
<strong>{t('layout_editor.save.guard_extension_reassembly_title')}</strong>
|
||||
<div style={{ marginTop: 4, fontSize: 12 }}>
|
||||
{t('layout_editor.save.guard_extension_reassembly', { count: String(result.unassigned) })}
|
||||
</div>
|
||||
</BannerShell>
|
||||
);
|
||||
}
|
||||
|
||||
if (result.kind === 'guard_no_document') {
|
||||
return (
|
||||
<BannerShell tone="neutral" testId="g7le-save-banner-guard-no-document" onDismiss={onDismiss}>
|
||||
|
||||
@@ -45,6 +45,7 @@ import type { EditorNode, NodeSource } from '../utils/layoutTreeUtils';
|
||||
import { trackEditorDocument } from '../devtools/editorTrackers';
|
||||
import { readSanctumToken } from '../utils/authToken';
|
||||
import { getCacheBustNonce, bumpCacheBustNonce } from '../utils/editorCacheBust';
|
||||
import { readConflictVersion } from '../utils/conflictVersion';
|
||||
import type { SaveResult } from './useLayoutDocument';
|
||||
import { suffixed } from '../../../support/assetUrl';
|
||||
|
||||
@@ -114,23 +115,90 @@ export function reassembleContent(
|
||||
return { ...content, components: cleaned };
|
||||
}
|
||||
if (Array.isArray(content.injections)) {
|
||||
const injections = (content.injections as any[]).map((inj) => ({ ...inj }));
|
||||
return reassembleOverlayContent(content, roots).content;
|
||||
}
|
||||
return content;
|
||||
}
|
||||
|
||||
/** overlay 재조립 결과 — 되돌리지 못한 노드 수를 함께 돌려준다(저장 가드 근거). */
|
||||
export interface OverlayReassembly {
|
||||
/** 저장용 content */
|
||||
content: Record<string, unknown>;
|
||||
/** 어느 injection 에서 왔는지 알 수 없어 버려질 뻔한 루트 노드 수 — 0 이 아니면 PUT 하지 않는다 */
|
||||
unassigned: number;
|
||||
}
|
||||
|
||||
/** 트리의 모든 노드 id 를 모은다(원본 injection 의 id 집합 — 순번 메타 없는 노드의 폴백 매칭용). */
|
||||
function collectNodeIds(nodes: unknown, out: Set<string>): void {
|
||||
if (!Array.isArray(nodes)) return;
|
||||
for (const n of nodes) {
|
||||
if (!n || typeof n !== 'object') continue;
|
||||
const id = (n as { id?: unknown }).id;
|
||||
if (typeof id === 'string' && id !== '') out.add(id);
|
||||
collectNodeIds((n as { children?: unknown }).children, out);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* overlay content 의 injections 를 편집 결과 루트로 되돌린다.
|
||||
*
|
||||
* 노드가 어느 injection 에서 왔는지는 세 단계로 판정한다 — ① 조각 단독 모드가 붙인
|
||||
* `__injectionIndex` ② 호스트 병합 모드에서 백엔드가 `__source.injectionIndex` 로 실어 준 순번
|
||||
* ③ 둘 다 없으면 원본 injection 의 노드 id 집합과 대조(구 백엔드 응답 호환). 셋 다 실패한
|
||||
* 노드는 **버리지 않고 센다** — 종전엔 조용히 버려져 호스트 병합 모드의 무변경 저장만으로
|
||||
* `injections[].components` 가 통째로 비워졌다(이커머스 → `_user_base` 헤더 통화 선택기 소실).
|
||||
*
|
||||
* @param content 원본 파싱 content(비편집 키 보존용)
|
||||
* @param roots 편집된 루트 노드 배열
|
||||
* @returns 저장용 content + 되돌리지 못한 노드 수
|
||||
*/
|
||||
export function reassembleOverlayContent(
|
||||
content: Record<string, unknown>,
|
||||
roots: EditorNode[],
|
||||
): OverlayReassembly {
|
||||
const original = Array.isArray(content.injections) ? (content.injections as any[]) : [];
|
||||
const injections = original.map((inj) => ({ ...inj }));
|
||||
const originalIds = original.map((inj) => {
|
||||
const ids = new Set<string>();
|
||||
if (inj && Array.isArray(inj.components)) collectNodeIds(inj.components, ids);
|
||||
return ids;
|
||||
});
|
||||
// components 를 가진 injection 의 components 를 비운 뒤 편집 결과로 재분배.
|
||||
injections.forEach((inj) => {
|
||||
if (Array.isArray(inj.components)) inj.components = [];
|
||||
});
|
||||
let unassigned = 0;
|
||||
for (const node of roots) {
|
||||
const idx = (node as any).__injectionIndex;
|
||||
const explicit = (node as any).__injectionIndex;
|
||||
const fromSource = (node as any).__source?.injectionIndex;
|
||||
let idx: number | undefined =
|
||||
typeof explicit === 'number' ? explicit : typeof fromSource === 'number' ? fromSource : undefined;
|
||||
if (idx === undefined) {
|
||||
const id = (node as { id?: unknown }).id;
|
||||
if (typeof id === 'string') {
|
||||
const found = originalIds.findIndex((ids) => ids.has(id));
|
||||
if (found >= 0) idx = found;
|
||||
}
|
||||
}
|
||||
const target =
|
||||
typeof idx === 'number' && idx >= 0 && idx < injections.length ? injections[idx] : null;
|
||||
if (target) {
|
||||
if (!target) {
|
||||
unassigned += 1;
|
||||
continue;
|
||||
}
|
||||
if (!Array.isArray(target.components)) target.components = [];
|
||||
target.components.push(stripExtensionEditorMeta(node));
|
||||
}
|
||||
}
|
||||
return { ...content, injections };
|
||||
}
|
||||
return content;
|
||||
return { content: { ...content, injections }, unassigned };
|
||||
}
|
||||
|
||||
/** 원본 overlay content 가 가진 주입 컴포넌트 루트 수 — 0 이면 잃을 것이 없어 가드가 불필요하다. */
|
||||
function countInjectionComponents(content: Record<string, unknown>): number {
|
||||
if (!Array.isArray(content.injections)) return 0;
|
||||
return (content.injections as any[]).reduce(
|
||||
(n, inj) => n + (inj && Array.isArray(inj.components) ? inj.components.length : 0),
|
||||
0,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -641,7 +709,29 @@ export function useExtensionDocument(): UseExtensionDocumentResult {
|
||||
const extracted = extractCurrentExtensionNodes(current.components, current.extensionId);
|
||||
const fragmentRoots = extracted.length > 0 ? extracted : current.components;
|
||||
// 편집한 components 를 원본 content 형태로 재조립(비편집 키 보존, 메타 제거).
|
||||
const contentToSave = reassembleContent(contentRef.current, fragmentRoots);
|
||||
let contentToSave: Record<string, unknown>;
|
||||
const isOverlay =
|
||||
Array.isArray(contentRef.current.injections) && !Array.isArray(contentRef.current.components);
|
||||
if (isOverlay) {
|
||||
// 손실 가드 — 되돌리지 못한 노드가 있고 원본에 잃을 컴포넌트가 있으면 PUT 하지 않는다.
|
||||
// (종전엔 그 노드를 조용히 버려 무변경 저장만으로 injections 가 비워졌다.)
|
||||
const reassembled = reassembleOverlayContent(contentRef.current, fragmentRoots);
|
||||
if (reassembled.unassigned > 0 && countInjectionComponents(contentRef.current) > 0) {
|
||||
trackEditorDocument({
|
||||
op: 'save',
|
||||
layoutName: `extension:${current.extensionId}`,
|
||||
editMode: 'extension',
|
||||
saveTarget: 'layout_extension',
|
||||
endpoint: url,
|
||||
isDirty,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
return { kind: 'guard_extension_reassembly', unassigned: reassembled.unassigned };
|
||||
}
|
||||
contentToSave = reassembled.content;
|
||||
} else {
|
||||
contentToSave = reassembleContent(contentRef.current, fragmentRoots);
|
||||
}
|
||||
|
||||
trackEditorDocument({
|
||||
op: 'save',
|
||||
@@ -710,8 +800,8 @@ export function useExtensionDocument(): UseExtensionDocumentResult {
|
||||
if (response.status === 409) {
|
||||
return {
|
||||
kind: 'concurrent_modification',
|
||||
currentVersion: (body as any)?.current_version ?? -1,
|
||||
yourVersion: (body as any)?.your_version ?? current.lockVersion,
|
||||
currentVersion: readConflictVersion(body, 'current_version') ?? -1,
|
||||
yourVersion: readConflictVersion(body, 'your_version') ?? current.lockVersion,
|
||||
};
|
||||
}
|
||||
if (response.status === 422) {
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { readConflictVersion } from '../utils/conflictVersion';
|
||||
import { useLayoutEditor } from '../LayoutEditorContext';
|
||||
import {
|
||||
buildEditorAccessError,
|
||||
@@ -130,7 +131,12 @@ export type SaveResult =
|
||||
| { kind: 'concurrent_modification'; currentVersion: number; yourVersion: number }
|
||||
| { kind: 'blocked_inactive_extension'; blockedPaths: string[] }
|
||||
| { kind: 'network_error'; message: string }
|
||||
| { kind: 'guard_no_document' };
|
||||
| { kind: 'guard_no_document' }
|
||||
/**
|
||||
* 확장 편집 모드(overlay) 저장 가드 — 추출한 노드 중 어느 injection 에서 왔는지 알 수 없는 것이
|
||||
* 있어 PUT 하지 않았다. 그대로 저장하면 그 노드가 버려져 injections 가 비워진다.
|
||||
*/
|
||||
| { kind: 'guard_extension_reassembly'; unassigned: number };
|
||||
|
||||
export interface UseLayoutDocumentResult {
|
||||
/** 현재 로드된 문서 (null = 로드 전 / 라우트 미선택) */
|
||||
@@ -940,12 +946,13 @@ export function useLayoutDocument(): UseLayoutDocumentResult {
|
||||
};
|
||||
}
|
||||
|
||||
// 409 — 동시 수정
|
||||
// 409 — 동시 수정. 서버(ResponseHelper::error)는 버전을 `errors` 아래에 싣는다 —
|
||||
// 최상위만 읽으면 배너가 「최신 버전: -1」 을 표시한다.
|
||||
if (response.status === 409) {
|
||||
return {
|
||||
kind: 'concurrent_modification',
|
||||
currentVersion: (body as any)?.current_version ?? -1,
|
||||
yourVersion: (body as any)?.your_version ?? current.lockVersion,
|
||||
currentVersion: readConflictVersion(body, 'current_version') ?? -1,
|
||||
yourVersion: readConflictVersion(body, 'your_version') ?? current.lockVersion,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* 409(동시 수정) 응답 본문의 버전 번호 판독.
|
||||
*
|
||||
* 서버(`ResponseHelper::error`)는 `current_version`/`your_version` 을 최상위가 아니라 `errors`
|
||||
* 아래에 싣는다. 최상위만 읽으면 값이 없어 -1 로 떨어지고 배너가 「최신 버전: -1」 을 표시한다.
|
||||
* 레이아웃 저장·확장 저장·inject_props 교차 저장 세 경로가 같은 판독을 쓴다.
|
||||
*
|
||||
* @since engine-v1.66.1
|
||||
*/
|
||||
|
||||
/**
|
||||
* @param body 파싱된 응답 본문(형식 미상)
|
||||
* @param key `current_version` | `your_version`
|
||||
* @returns 숫자 또는 undefined — `errors.{key}` 우선, 최상위 폴백(구 응답 형식 호환)
|
||||
*/
|
||||
export function readConflictVersion(
|
||||
body: unknown,
|
||||
key: 'current_version' | 'your_version',
|
||||
): number | undefined {
|
||||
const b = body as ({ errors?: Record<string, unknown> } & Record<string, unknown>) | null | undefined;
|
||||
const nested = b?.errors?.[key];
|
||||
if (typeof nested === 'number') return nested;
|
||||
const top = b?.[key];
|
||||
return typeof top === 'number' ? top : undefined;
|
||||
}
|
||||
@@ -15,6 +15,7 @@
|
||||
*/
|
||||
|
||||
import { buildAuthHeaders } from './authToken';
|
||||
import { readConflictVersion } from './conflictVersion';
|
||||
|
||||
/** 교차 저장 결과 */
|
||||
export type InjectedPropsSaveResult =
|
||||
@@ -112,8 +113,8 @@ export async function saveInjectedPropsToExtension(
|
||||
if (putRes.status === 409) {
|
||||
return {
|
||||
kind: 'conflict',
|
||||
currentVersion: (putBody as any)?.current_version ?? -1,
|
||||
yourVersion: (putBody as any)?.your_version ?? lockVersion,
|
||||
currentVersion: readConflictVersion(putBody, 'current_version') ?? -1,
|
||||
yourVersion: readConflictVersion(putBody, 'your_version') ?? lockVersion,
|
||||
};
|
||||
}
|
||||
return { kind: 'error', message: (putBody as any)?.message ?? `HTTP ${putRes.status}` };
|
||||
|
||||
@@ -31,6 +31,11 @@ export interface NodeSource {
|
||||
extensionIdentifier?: string;
|
||||
/** 확장 로케일 표시명(예: `게시판`) — 식별자와 함께 표시 */
|
||||
extensionName?: string;
|
||||
/**
|
||||
* overlay 확장 `injections[]` 순번 — 백엔드가 주입 시점에 부여. 확장 편집 모드 저장이 호스트
|
||||
* 병합 트리에서 추출한 노드를 원래 injection 으로 되돌리는 열쇠(extension_point 주입은 없음).
|
||||
*/
|
||||
injectionIndex?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Api\Public;
|
||||
|
||||
use App\Enums\ExtensionStatus;
|
||||
use App\Enums\LayoutExtensionType;
|
||||
use App\Enums\LayoutSourceType;
|
||||
use App\Models\LayoutExtension;
|
||||
use App\Models\Template;
|
||||
use App\Services\LayoutExtensionService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* [case:backend-34] overlay 확장이 주입한 노드의 `__source` 에 `injectionIndex` 가 실린다
|
||||
*
|
||||
* 편집기 확장 편집 모드는 호스트 병합 트리(`with_source_meta=1`)에서 현재 확장의 노드를
|
||||
* 추출해 `injections[].components` 로 되돌려 저장한다. 어느 injection 에서 왔는지가
|
||||
* 메타에 없으면 재조립이 모든 노드를 버려 저장본의 injections 가 통째로 비워진다
|
||||
* (실측: 이커머스 → `_user_base` 헤더 통화 선택기 18,405B → 148B). 백엔드가 주입 시점에
|
||||
* injection 순번을 메타로 싣는 것이 SSoT 다.
|
||||
*/
|
||||
class LayoutOverlayInjectionIndexMetaTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
/**
|
||||
* @var array<string>
|
||||
*/
|
||||
protected array $requiredExtensions = [
|
||||
'plugins/sirsoft-gdpr',
|
||||
];
|
||||
|
||||
#[Test]
|
||||
public function overlay_injected_nodes_carry_injection_index_in_source_meta(): void
|
||||
{
|
||||
$template = Template::factory()->create([
|
||||
'identifier' => 'sirsoft-basic',
|
||||
'type' => 'user',
|
||||
'status' => ExtensionStatus::Active->value,
|
||||
]);
|
||||
|
||||
$extension = LayoutExtension::create([
|
||||
'template_id' => $template->id,
|
||||
'extension_type' => LayoutExtensionType::Overlay,
|
||||
'target_name' => '_user_base',
|
||||
// 출처를 템플릿 자신으로 두어 모듈 활성 여부 게이트(isExtensionSourceActive)에 걸리지 않게 한다.
|
||||
'source_type' => LayoutSourceType::Template,
|
||||
'source_identifier' => 'sirsoft-basic',
|
||||
'content' => [
|
||||
'target_layout' => '_user_base',
|
||||
'injections' => [
|
||||
// 0 — inject_props (components 없음). 순번은 이 injection 도 센다.
|
||||
['target_id' => 'anchor_a', 'position' => 'inject_props', 'props' => ['className' => 'x']],
|
||||
// 1 — 컴포넌트 주입
|
||||
[
|
||||
'target_id' => 'anchor_b',
|
||||
'position' => 'append_child',
|
||||
'components' => [
|
||||
[
|
||||
'type' => 'basic',
|
||||
'name' => 'Span',
|
||||
'id' => 'ext_b',
|
||||
'children' => [
|
||||
['type' => 'basic', 'name' => 'Span', 'id' => 'ext_b_child'],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
'priority' => 320,
|
||||
],
|
||||
'priority' => 320,
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$layout = [
|
||||
'layout_name' => '_user_base',
|
||||
'meta' => ['title' => 'Base'],
|
||||
'components' => [
|
||||
['type' => 'basic', 'name' => 'Div', 'id' => 'anchor_a'],
|
||||
['type' => 'basic', 'name' => 'Div', 'id' => 'anchor_b'],
|
||||
],
|
||||
];
|
||||
|
||||
// 편집 모드(with_source_meta) 병합 — PublicLayoutController::serve 가 같은 인자로 호출한다.
|
||||
$merged = $this->app->make(LayoutExtensionService::class)
|
||||
->applyExtensions($layout, $template->id, true);
|
||||
|
||||
$injected = $this->findNodeById($merged['components'], 'ext_b');
|
||||
$this->assertNotNull($injected, '주입 노드 ext_b 가 병합 결과에 있어야 한다');
|
||||
$this->assertSame('extension', $injected['__source']['kind'] ?? null);
|
||||
$this->assertSame($extension->id, $injected['__source']['extensionId'] ?? null);
|
||||
$this->assertSame(
|
||||
1,
|
||||
$injected['__source']['injectionIndex'] ?? null,
|
||||
'주입 노드 진입점의 __source 에 원래 injection 순번(1)이 실려야 편집기가 저장 시 되돌릴 수 있다'
|
||||
);
|
||||
|
||||
// 자식도 같은 순번을 상속(진입점 판정은 자식으로 내려가지 않지만 메타는 일관).
|
||||
$child = $this->findNodeById($merged['components'], 'ext_b_child');
|
||||
$this->assertSame(1, $child['__source']['injectionIndex'] ?? null);
|
||||
|
||||
// 운영 렌더(with_source_meta=false)에는 메타가 전혀 없다 — 비파괴.
|
||||
$plain = $this->app->make(LayoutExtensionService::class)
|
||||
->applyExtensions($layout, $template->id, false);
|
||||
$plainInjected = $this->findNodeById($plain['components'], 'ext_b');
|
||||
$this->assertNotNull($plainInjected);
|
||||
$this->assertArrayNotHasKey('__source', $plainInjected);
|
||||
}
|
||||
|
||||
/**
|
||||
* 트리에서 id 로 노드를 찾는다.
|
||||
*
|
||||
* @param array<int, mixed>|null $nodes 컴포넌트 배열
|
||||
* @param string $id 찾을 id
|
||||
* @return array<string, mixed>|null 노드
|
||||
*/
|
||||
private function findNodeById(?array $nodes, string $id): ?array
|
||||
{
|
||||
foreach ($nodes ?? [] as $node) {
|
||||
if (! is_array($node)) {
|
||||
continue;
|
||||
}
|
||||
if (($node['id'] ?? null) === $id) {
|
||||
return $node;
|
||||
}
|
||||
$found = $this->findNodeById($node['children'] ?? null, $id);
|
||||
if ($found !== null) {
|
||||
return $found;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Api\Public;
|
||||
|
||||
use App\Enums\ExtensionStatus;
|
||||
use App\Models\Template;
|
||||
use App\Models\TemplateLayout;
|
||||
use App\Services\LayoutService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* [case:backend-33] 편집기가 부팅 시점 cache_version 으로 계속 요청해도 저장 뒤에는 최신 content 가 온다
|
||||
*
|
||||
* 레이아웃 편집기는 `window.G7Config.cache_version`(부팅 시점 값)에 nonce 만 붙여
|
||||
* `?v={부팅버전}.{nonce}` 로 재로드한다. 저장·복원은 `ext.cache_version` 을 `time()` 으로
|
||||
* 올리고 **서버 현재 버전** 키만 지우므로, 서빙 캐시 키를 클라이언트가 보낸 버전으로 조립하면
|
||||
* 두 번째 bump 부터 부팅 버전 키가 영영 지워지지 않아 초기화·복원·409 「최신 불러오기」가
|
||||
* 옛 content 를 받는다(실측: 초기화 직후 lock 4 응답, DB 는 lock 7). 서빙 캐시 키는 서버 현재
|
||||
* 버전으로만 조립하고 `?v` 는 브라우저 HTTP 캐시 우회용으로만 쓴다.
|
||||
*/
|
||||
class LayoutServingStaleClientVersionKeyTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
/**
|
||||
* @var array<string>
|
||||
*/
|
||||
protected array $requiredExtensions = [
|
||||
'plugins/sirsoft-gdpr',
|
||||
];
|
||||
|
||||
#[Test]
|
||||
public function serve_with_boot_time_client_version_returns_fresh_content_after_two_saves(): void
|
||||
{
|
||||
$template = Template::create([
|
||||
'identifier' => 'sirsoft-admin_basic',
|
||||
'vendor' => 'sirsoft',
|
||||
'name' => ['ko' => '기본 관리자 템플릿', 'en' => 'Basic Admin Template'],
|
||||
'version' => '1.0.0',
|
||||
'type' => 'admin',
|
||||
'status' => ExtensionStatus::Active->value,
|
||||
'description' => ['ko' => '기본 관리자 템플릿', 'en' => 'Basic Admin Template'],
|
||||
]);
|
||||
|
||||
$layout = TemplateLayout::create([
|
||||
'template_id' => $template->id,
|
||||
'name' => 'dashboard',
|
||||
'content' => $this->content('Before'),
|
||||
]);
|
||||
|
||||
// 편집기 부팅 시점의 확장 캐시 버전 — 이후 요청은 모두 이 정수에 nonce 만 붙인다.
|
||||
Cache::put('g7:core:ext.cache_version', 1000);
|
||||
$url = "/api/layouts/{$template->identifier}/dashboard.json?v=1000.";
|
||||
|
||||
$this->assertSame('Before', $this->getJson($url.'0')->assertStatus(200)->json('data.meta.title'));
|
||||
|
||||
$service = app(LayoutService::class);
|
||||
|
||||
// 저장 1 — 서빙 캐시 무효화 + cache_version bump(time()).
|
||||
$service->updateLayout($template->id, 'dashboard', [
|
||||
'content' => $this->content('After 1'),
|
||||
'expected_lock_version' => (int) $layout->fresh()->lock_version,
|
||||
]);
|
||||
// 편집기 재로드(초기화·복원 뒤) — 부팅 버전 키가 다시 채워진다.
|
||||
$this->assertSame('After 1', $this->getJson($url.'1')->assertStatus(200)->json('data.meta.title'));
|
||||
|
||||
// 저장 2 — 서버는 현재(bump 된) 버전 키만 지운다.
|
||||
$service->updateLayout($template->id, 'dashboard', [
|
||||
'content' => $this->content('After 2'),
|
||||
'expected_lock_version' => (int) $layout->fresh()->lock_version,
|
||||
]);
|
||||
|
||||
// 편집기 재로드 — 부팅 버전으로 요청해도 최신이어야 한다(종전: 'After 1' stale).
|
||||
$this->assertSame(
|
||||
'After 2',
|
||||
$this->getJson($url.'2')->assertStatus(200)->json('data.meta.title'),
|
||||
'부팅 시점 cache_version 으로 조립된 서빙 캐시 키가 두 번째 저장 뒤 stale 로 남는다'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $title meta.title
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function content(string $title): array
|
||||
{
|
||||
return [
|
||||
'meta' => ['title' => $title],
|
||||
'data_sources' => [],
|
||||
'components' => [
|
||||
['type' => 'basic', 'name' => 'Div', 'props' => ['className' => 'container'], 'children' => []],
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ use App\Extension\Cache\CoreCacheDriver;
|
||||
use App\Extension\Traits\InvalidatesLayoutCache;
|
||||
use App\Models\Template;
|
||||
use App\Models\TemplateLayout;
|
||||
use App\Services\ExtensionStaticCacheService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use Tests\TestCase;
|
||||
@@ -70,7 +71,9 @@ class LayoutEditorCacheInvalidationTest extends TestCase
|
||||
]);
|
||||
|
||||
$cache = $this->coreCache();
|
||||
$version = (int) $cache->get('ext.cache_version', 0);
|
||||
// 트레이트 게터와 같은 근거 — 원시 키 읽기는 키 부재 시 0 을 돌려주어 실제 무효화 키(`.v{time}`)와
|
||||
// 어긋난 `.v0` 키를 만든다(트레이트는 부재 시 재생성). 무효화 대상 키는 게터가 SSoT 다.
|
||||
$version = ExtensionStaticCacheService::getExtensionCacheVersion();
|
||||
|
||||
// PublicLayoutController::serve() 가 쓰는 두 캐시 키 — 일반 + 편집기(.meta)
|
||||
$generalKey = "layout.sirsoft-basic.home.v{$version}";
|
||||
@@ -105,7 +108,9 @@ class LayoutEditorCacheInvalidationTest extends TestCase
|
||||
]);
|
||||
|
||||
$cache = $this->coreCache();
|
||||
$version = (int) $cache->get('ext.cache_version', 0);
|
||||
// 트레이트 게터와 같은 근거 — 원시 키 읽기는 키 부재 시 0 을 돌려주어 실제 무효화 키(`.v{time}`)와
|
||||
// 어긋난 `.v0` 키를 만든다(트레이트는 부재 시 재생성). 무효화 대상 키는 게터가 SSoT 다.
|
||||
$version = ExtensionStaticCacheService::getExtensionCacheVersion();
|
||||
$metaKey = "layout.sirsoft-basic.home.v{$version}.meta";
|
||||
$cache->put($metaKey, ['stale' => 'editor-old-content'], 3600);
|
||||
|
||||
|
||||
@@ -232,15 +232,20 @@ class PublicLayoutControllerTest extends TestCase
|
||||
}
|
||||
|
||||
/**
|
||||
* 편집기 캐시-버스트 nonce(`?v={version}.{nonce}`) 요청 시 서버 캐시 키는 **정수 버전**만 쓴다.
|
||||
* 편집기 캐시-버스트 nonce(`?v={version}.{nonce}`) 요청 시 서버 캐시 키는 **서버 현재 버전**만 쓴다.
|
||||
*
|
||||
* 레이아웃 편집기는 같은 세션 저장/복원 후 브라우저 HTTP 캐시를 우회하려고 `?v={cacheVersion}.{nonce}`
|
||||
* 형식으로 요청한다. 종전엔 `serve` 가 이 문자열을 그대로 캐시 키에 써(`v{ver}.{nonce}.meta`),
|
||||
* 저장 경로 `LayoutService::clearPublicServingCache` 가 `(int) ext.cache_version` 으로 nonce 없는 키만
|
||||
* forget 하므로 무효화가 빗나가 편집기가 stale 응답을 받았다. 본 테스트는 nonce 가 붙은 요청도
|
||||
* **정수 버전 키**(`v{ver}.meta`)로만 캐싱돼, 저장 경로 무효화 키와 정합함을 잠근다.
|
||||
* forget 하므로 무효화가 빗나가 편집기가 stale 응답을 받았다(#588 — 정수부만 키에 사용).
|
||||
*
|
||||
* 그 뒤에도 사각이 남았다 — 편집기는 **부팅 시점** 버전을 계속 보내고 서버는 저장·복원마다 버전을
|
||||
* 올리며 **현재** 버전 키만 지우므로, 두 번째 bump 부터 부팅 버전 키가 영영 남아 초기화·복원·409
|
||||
* 「최신 불러오기」가 옛 content 를 받았다. 본 테스트는 클라이언트 `?v` 가 서버 버전과 달라도 서버
|
||||
* 캐시 키가 **서버 현재 버전**(`v{server}.meta`)으로만 만들어져, 무효화 키와 굽기 키가 항상 같은
|
||||
* 키임을 잠근다.
|
||||
*/
|
||||
public function test_editor_nonce_versioned_request_uses_integer_cache_key(): void
|
||||
public function test_editor_nonce_versioned_request_uses_server_cache_version_key(): void
|
||||
{
|
||||
$template = Template::create([
|
||||
'identifier' => 'sirsoft-admin_basic',
|
||||
@@ -258,8 +263,11 @@ class PublicLayoutControllerTest extends TestCase
|
||||
]);
|
||||
|
||||
$coreCache = app(CacheInterface::class);
|
||||
// 클라이언트(편집기 부팅 시점) 버전과 서버 현재 버전을 다르게 둔다 — 저장 뒤의 실제 상태.
|
||||
$version = 1781151505;
|
||||
$serverVersion = 1781151999;
|
||||
$nonce = 7;
|
||||
Cache::put('g7:core:ext.cache_version', $serverVersion);
|
||||
// 편집 모드(.meta) — `core.templates.layouts.edit` 권한 보유 사용자로 인증.
|
||||
$admin = User::factory()->create();
|
||||
$role = Role::create([
|
||||
@@ -275,7 +283,8 @@ class PublicLayoutControllerTest extends TestCase
|
||||
]);
|
||||
$role->permissions()->attach($perm->id);
|
||||
$admin->roles()->attach($role->id);
|
||||
// 정수 버전 키(저장 경로가 forget 하는 키)와 nonce 부착 키를 모두 비워 둔다.
|
||||
// 서버 버전 키(저장 경로가 forget 하는 키)·클라이언트 정수 키·nonce 부착 키를 모두 비워 둔다.
|
||||
$coreCache->forget("layout.{$template->identifier}.{$layout->name}.v{$serverVersion}.meta");
|
||||
$coreCache->forget("layout.{$template->identifier}.{$layout->name}.v{$version}.meta");
|
||||
$coreCache->forget("layout.{$template->identifier}.{$layout->name}.v{$version}.{$nonce}.meta");
|
||||
|
||||
@@ -284,10 +293,16 @@ class PublicLayoutControllerTest extends TestCase
|
||||
->getJson("/api/layouts/{$template->identifier}/{$layout->name}.json?with_source_meta=1&v={$version}.{$nonce}")
|
||||
->assertStatus(200);
|
||||
|
||||
// 서버 캐시 키는 **정수 버전**(nonce 없는 키)에 생성돼야 한다 — 저장 경로 무효화 키와 일치.
|
||||
// 서버 캐시 키는 **서버 현재 버전**(nonce 없는 키)에 생성돼야 한다 — 저장 경로 무효화 키와 일치.
|
||||
$this->assertTrue(
|
||||
$coreCache->has("layout.{$template->identifier}.{$layout->name}.v{$serverVersion}.meta"),
|
||||
'요청의 ?v 와 무관하게 서버 현재 버전 캐시 키(`v{server}.meta`)로 캐싱돼야 저장 경로 무효화와 정합함',
|
||||
);
|
||||
// 클라이언트가 보낸 정수 버전 키는 생성되지 않아야 한다 — 저장 경로가 지우지 않는 키라
|
||||
// 두 번째 저장부터 stale 로 남는다(편집기 초기화·복원·409 「최신 불러오기」 옛 content 회귀 차단).
|
||||
$this->assertFalse(
|
||||
$coreCache->has("layout.{$template->identifier}.{$layout->name}.v{$version}.meta"),
|
||||
'nonce 부착 요청도 정수 버전 캐시 키(`v{ver}.meta`)로 캐싱돼야 저장 경로 무효화와 정합함',
|
||||
'클라이언트 부팅 버전으로 키를 만들면 저장 경로(서버 현재 버전 키 forget)가 무효화하지 못해 stale 회귀',
|
||||
);
|
||||
// nonce 가 박힌 키(`v{ver}.{nonce}.meta`)는 생성되지 않아야 한다(무효화 빗나감 회귀 차단).
|
||||
$this->assertFalse(
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\Repositories\Concerns;
|
||||
|
||||
use App\Repositories\Concerns\CalculatesJsonContentDiff;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* 버전 변경량(라인 LCS 카운트) 계산의 메모리 상한과 카운트 불변을 고정합니다. [case:backend-32]
|
||||
*
|
||||
* 큰 공통 레이아웃(직렬화 2,000줄 이상)의 첫 편집기 저장은 변경 영역이 파일 전체가 된다 —
|
||||
* 편집기가 `comment` 키를 떼어내 첫 줄과 끝 근처가 동시에 달라져 prefix/suffix 트리밍이
|
||||
* 무력해지기 때문이다. 종전 구현은 그 영역의 (줄 수)² 크기 PHP 배열을 만들어 2,350줄에서
|
||||
* 약 146MB 를 썼고, PHP 기본 memory_limit(128M) 인 서버에서 저장이 500 으로 끝났다.
|
||||
* 개발 머신은 512M 이라 드러나지 않았다.
|
||||
*
|
||||
* 카운트는 LCS 길이만으로 결정된다(추가 = 새 줄 − LCS, 삭제 = 옛 줄 − LCS). 두 행만 쓰는
|
||||
* 계산으로 바꿔도 숫자가 종전(전체 표 + backtrack)과 같아야 목록의 +N/-N 이 diff 뷰와
|
||||
* 계속 일치한다 — 그 동치를 참조 구현 대비 무작위 입력으로 잠근다.
|
||||
*/
|
||||
class CalculatesJsonContentDiffTest extends TestCase
|
||||
{
|
||||
/**
|
||||
* 트레이트를 조합한 익명 객체 (private 메서드를 공개 래퍼로 노출)
|
||||
*
|
||||
* @return object
|
||||
*/
|
||||
private function subject(): object
|
||||
{
|
||||
return new class
|
||||
{
|
||||
use CalculatesJsonContentDiff;
|
||||
|
||||
/** @param array<string> $a @param array<string> $b @return array{0: int, 1: int} */
|
||||
public function counts(array $a, array $b): array
|
||||
{
|
||||
return $this->diffLineCounts($a, $b);
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $content @return array<string> */
|
||||
public function lines(array $content): array
|
||||
{
|
||||
return $this->contentToLines($content);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 종전 구현(전체 DP 표 + backtrack) — 카운트 동치의 참조 기준.
|
||||
*
|
||||
* @param array<string> $a 이전 라인
|
||||
* @param array<string> $b 새 라인
|
||||
* @return array{0: int, 1: int} [추가, 삭제]
|
||||
*/
|
||||
private function referenceCounts(array $a, array $b): array
|
||||
{
|
||||
$na = count($a);
|
||||
$nb = count($b);
|
||||
$dp = array_fill(0, $na + 1, array_fill(0, $nb + 1, 0));
|
||||
for ($i = $na - 1; $i >= 0; $i--) {
|
||||
for ($j = $nb - 1; $j >= 0; $j--) {
|
||||
$dp[$i][$j] = $a[$i] === $b[$j]
|
||||
? $dp[$i + 1][$j + 1] + 1
|
||||
: max($dp[$i + 1][$j], $dp[$i][$j + 1]);
|
||||
}
|
||||
}
|
||||
$added = 0;
|
||||
$removed = 0;
|
||||
$i = 0;
|
||||
$j = 0;
|
||||
while ($i < $na && $j < $nb) {
|
||||
if ($a[$i] === $b[$j]) {
|
||||
$i++;
|
||||
$j++;
|
||||
} elseif ($dp[$i + 1][$j] >= $dp[$i][$j + 1]) {
|
||||
$removed++;
|
||||
$i++;
|
||||
} else {
|
||||
$added++;
|
||||
$j++;
|
||||
}
|
||||
}
|
||||
|
||||
return [$added + ($nb - $j), $removed + ($na - $i)];
|
||||
}
|
||||
|
||||
/**
|
||||
* 직렬화하면 2,300줄을 넘는 레이아웃 형태의 content 를 만듭니다.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function largeLayout(): array
|
||||
{
|
||||
$components = [];
|
||||
for ($i = 0; $i < 260; $i++) {
|
||||
$components[] = [
|
||||
'id' => "node_{$i}",
|
||||
'type' => 'basic',
|
||||
'name' => 'Div',
|
||||
'props' => ['className' => "p-{$i} flex", 'title' => "노드 {$i}"],
|
||||
'children' => [],
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'components' => $components,
|
||||
'layout_name' => 'big',
|
||||
'version' => '1.0.0',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 변경 영역이 파일 전체인 큰 레이아웃의 카운트 계산이 메모리 예산 안에 머물러야 합니다.
|
||||
*
|
||||
* 이전 content 에는 정렬 시 맨 앞에 오는 `comment` 와 맨 뒤에 오는 `zzz` 가 있고 새
|
||||
* content 에는 둘 다 없다 — 편집기 첫 저장이 만드는 정확히 그 형태다. 종전 구현은 여기서
|
||||
* 146MB 를 더 썼다.
|
||||
*
|
||||
* @effects changes_summary_memory_bounded_for_full_span_change
|
||||
*/
|
||||
public function test_full_span_change_of_large_layout_stays_within_memory_budget(): void
|
||||
{
|
||||
$subject = $this->subject();
|
||||
$new = $this->largeLayout();
|
||||
$old = ['comment' => '상속원 설명'] + $new + ['zzz' => 'x'];
|
||||
|
||||
$oldLines = $subject->lines($old);
|
||||
$newLines = $subject->lines($new);
|
||||
$this->assertGreaterThan(2300, count($oldLines), '재현 조건: 직렬화 2,300줄 이상');
|
||||
|
||||
gc_collect_cycles();
|
||||
$before = memory_get_peak_usage();
|
||||
[$added, $removed] = $subject->counts($oldLines, $newLines);
|
||||
$grown = memory_get_peak_usage() - $before;
|
||||
|
||||
$this->assertLessThan(
|
||||
16 * 1024 * 1024,
|
||||
$grown,
|
||||
sprintf('카운트 계산이 %.1fMB 를 더 썼습니다 — (줄 수)² 표를 만들고 있습니다', $grown / 1048576)
|
||||
);
|
||||
// comment 삭제 + zzz 삭제 + `"version": "1.0.0",` → `"version": "1.0.0"` (삭제 1·추가 1)
|
||||
$this->assertSame([1, 3], [$added, $removed]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 카운트는 종전 구현(전체 표 + backtrack)과 무작위 입력에서 항상 같아야 합니다 —
|
||||
* 목록의 +N/-N 이 diff 뷰(프론트 lineDiff.ts)와 일치한다는 S11 계약의 근거다.
|
||||
*
|
||||
* @effects changes_summary_uses_line_lcs_unit_matching_diff_view
|
||||
*/
|
||||
public function test_counts_equal_full_table_reference_on_random_inputs(): void
|
||||
{
|
||||
$subject = $this->subject();
|
||||
mt_srand(20260909);
|
||||
|
||||
for ($case = 0; $case < 600; $case++) {
|
||||
$alphabet = mt_rand(1, 6); // 작은 알파벳 = 중복 라인(`}`, `],`)이 많은 레이아웃 형태
|
||||
$a = [];
|
||||
$b = [];
|
||||
for ($i = 0, $n = mt_rand(0, 40); $i < $n; $i++) {
|
||||
$a[] = chr(97 + mt_rand(0, $alphabet));
|
||||
}
|
||||
for ($i = 0, $m = mt_rand(0, 40); $i < $m; $i++) {
|
||||
$b[] = chr(97 + mt_rand(0, $alphabet));
|
||||
}
|
||||
|
||||
$this->assertSame(
|
||||
$this->referenceCounts($a, $b),
|
||||
$subject->counts($a, $b),
|
||||
sprintf('case %d: %s vs %s', $case, implode('', $a), implode('', $b))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 레이아웃 형태의 변형(노드 추가·삭제·prop 변경·순서 변경)에서도 참조 구현과 같아야 합니다.
|
||||
*
|
||||
* @effects changes_summary_uses_line_lcs_unit_matching_diff_view
|
||||
*/
|
||||
public function test_counts_equal_reference_on_layout_shaped_mutations(): void
|
||||
{
|
||||
$subject = $this->subject();
|
||||
$base = [
|
||||
'components' => [
|
||||
['id' => 'a', 'type' => 'basic', 'name' => 'Div', 'props' => ['className' => 'x'], 'children' => []],
|
||||
['id' => 'b', 'type' => 'basic', 'name' => 'Span', 'props' => ['text' => '안녕'], 'children' => []],
|
||||
['id' => 'c', 'type' => 'composite', 'name' => 'Header', 'props' => ['logo' => '/a.png']],
|
||||
],
|
||||
'layout_name' => 'home',
|
||||
'version' => '1.0.0',
|
||||
];
|
||||
|
||||
$mutations = [
|
||||
'prop 변경' => (function () use ($base) {
|
||||
$base['components'][2]['props']['logo'] = '/api/templates/x/layout-attachments/1/file';
|
||||
|
||||
return $base;
|
||||
})(),
|
||||
'노드 추가' => (function () use ($base) {
|
||||
$base['components'][] = ['id' => 'd', 'type' => 'basic', 'name' => 'P', 'props' => []];
|
||||
|
||||
return $base;
|
||||
})(),
|
||||
'노드 삭제' => (function () use ($base) {
|
||||
unset($base['components'][1]);
|
||||
$base['components'] = array_values($base['components']);
|
||||
|
||||
return $base;
|
||||
})(),
|
||||
'순서 변경' => (function () use ($base) {
|
||||
$base['components'] = [$base['components'][2], $base['components'][0], $base['components'][1]];
|
||||
|
||||
return $base;
|
||||
})(),
|
||||
'빈 content' => [],
|
||||
];
|
||||
|
||||
$baseLines = $subject->lines($base);
|
||||
foreach ($mutations as $label => $mutated) {
|
||||
$mutatedLines = $subject->lines($mutated);
|
||||
$this->assertSame($this->referenceCounts($baseLines, $mutatedLines), $subject->counts($baseLines, $mutatedLines), $label);
|
||||
$this->assertSame($this->referenceCounts($mutatedLines, $baseLines), $subject->counts($mutatedLines, $baseLines), $label.' (역방향)');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ namespace Tests\Unit\Repositories;
|
||||
use App\Models\LayoutExtension;
|
||||
use App\Models\Template;
|
||||
use App\Models\TemplateLayoutExtensionVersion;
|
||||
use App\Models\User;
|
||||
use App\Repositories\LayoutExtensionVersionRepository;
|
||||
use Illuminate\Database\Eloquent\ModelNotFoundException;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
@@ -212,4 +213,37 @@ class LayoutExtensionVersionRepositoryTest extends TestCase
|
||||
$this->assertSame([], $this->repository->getCurrentVersionsByExtensionIds([]));
|
||||
$this->assertSame([], $this->repository->getCurrentVersionsByExtensionIds([$this->extension->id]));
|
||||
}
|
||||
|
||||
/**
|
||||
* saveVersion 은 인증 사용자를 created_by 로 기록한다(레이아웃 본체와 동형).
|
||||
*/
|
||||
public function test_save_version_records_authenticated_user_as_creator(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$this->actingAs($user);
|
||||
|
||||
$version = $this->repository->saveVersion($this->extension->id, ['extension_point' => 'header', 'components' => []]);
|
||||
|
||||
$this->assertSame($user->id, $version->created_by);
|
||||
}
|
||||
|
||||
/**
|
||||
* restoreVersion 은 확장 lock_version 을 올린다(레이아웃 본체와 동형).
|
||||
*/
|
||||
public function test_restore_version_increments_extension_lock_version(): void
|
||||
{
|
||||
$oldContent = ['extension_point' => 'header', 'components' => [['type' => 'basic', 'name' => 'Span']]];
|
||||
$this->extension->forceFill(['lock_version' => 2, 'content' => ['extension_point' => 'footer']])->save();
|
||||
$target = TemplateLayoutExtensionVersion::factory()->create([
|
||||
'extension_id' => $this->extension->id,
|
||||
'version' => 1,
|
||||
'content' => $oldContent,
|
||||
]);
|
||||
|
||||
$this->repository->restoreVersion($this->extension->id, $target->id);
|
||||
|
||||
$this->extension->refresh();
|
||||
$this->assertSame(3, (int) $this->extension->lock_version);
|
||||
$this->assertEquals($oldContent, $this->extension->content);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace Tests\Unit\Repositories;
|
||||
use App\Models\Template;
|
||||
use App\Models\TemplateLayout;
|
||||
use App\Models\TemplateLayoutVersion;
|
||||
use App\Models\User;
|
||||
use App\Repositories\LayoutVersionRepository;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
@@ -687,4 +688,41 @@ class LayoutVersionRepositoryTest extends TestCase
|
||||
|
||||
$this->assertSame([], $map);
|
||||
}
|
||||
|
||||
/**
|
||||
* saveVersion 은 인증 사용자를 created_by 로 기록한다 — 종전엔 항상 NULL 이라 버전 목록의
|
||||
* 저장자가 「알 수 없음」으로만 표시됐다.
|
||||
*/
|
||||
public function test_save_version_records_authenticated_user_as_creator(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$this->actingAs($user);
|
||||
|
||||
$version = $this->repository->saveVersion($this->layout->id, ['components' => []]);
|
||||
|
||||
$this->assertSame($user->id, $version->created_by);
|
||||
}
|
||||
|
||||
/**
|
||||
* restoreVersion 은 레이아웃 lock_version 을 올린다 — 복원 직전 화면을 열어 둔 다른 편집기가
|
||||
* 복원 결과를 409 없이 덮지 못하게 한다.
|
||||
*/
|
||||
public function test_restore_version_increments_layout_lock_version(): void
|
||||
{
|
||||
$this->layout->forceFill([
|
||||
'lock_version' => 4,
|
||||
'content' => ['components' => [['type' => 'basic', 'name' => 'Span']]],
|
||||
])->save();
|
||||
$target = TemplateLayoutVersion::factory()->create([
|
||||
'layout_id' => $this->layout->id,
|
||||
'version' => 1,
|
||||
'content' => ['components' => []],
|
||||
]);
|
||||
|
||||
$this->repository->restoreVersion($this->layout->id, $target->id);
|
||||
|
||||
$this->layout->refresh();
|
||||
$this->assertSame(5, (int) $this->layout->lock_version);
|
||||
$this->assertEquals(['components' => []], $this->layout->content);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,7 +54,9 @@ effects:
|
||||
- stable_stringify_sorts_keys_preserves_array_order
|
||||
- changes_summary_uses_line_lcs_unit_matching_diff_view
|
||||
- changes_summary_recalculated_for_existing_versions
|
||||
- changes_summary_memory_bounded_for_full_span_change
|
||||
test_files:
|
||||
- tests/Unit/Repositories/Concerns/CalculatesJsonContentDiffTest.php
|
||||
- resources/js/core/template-engine/layout-editor/__tests__/components/VersionHistoryModal.test.tsx
|
||||
- resources/js/core/template-engine/layout-editor/__tests__/components/EditorToolbar.versionPreview.test.tsx
|
||||
- resources/js/core/template-engine/layout-editor/__tests__/components/VersionDiffView.test.tsx
|
||||
|
||||
Reference in New Issue
Block a user