Files
Gnuboard7/tests/Unit/Extension/TemplateUpdateConfigCacheInvalidationTest.php
T
HeuJung 0c4f52fc96 fix(core): 업데이트 트리 argv 판정을 명령줄 SAPI 로 한정하고 매니페스트 삭제 실패를 로그에 남김
7.0.11 인스톨러·코어 업데이트 변경(e60a82d73)에 대해 과거 회귀 22건을 부류별로 대조한
결과, 신규 노출면 1건과 테스트 위생 1건이 나와 인터뷰 결정대로 조치했다.

1. argv 채널의 SAPI 게이트 — CGI/FPM 은 register_argc_argv=On 이면 $_SERVER['argv'] 를
 쿼리스트링을 '+' 로 쪼개 채우므로(`GET /?x+core:update` → argv[1]==='core:update', php-cgi
 실측) 비인증 웹 요청이 업데이트 트리로 판정되어 bootstrap/app.php 자가 치유가 요청마다
 패키지 매니페스트를 지우고 다시 만들었다. CoreUpdateContext 와 bootstrap/app.php 복제본
 모두 argv 를 cli·phpdbg 에서만 읽는다. env 플래그 채널은 웹에서 주입할 수 없으므로 그대로
 두어 웹 요청 안에서 시작하는 업데이트 흐름(7.1.0)에 영향이 없다. 동형성 테스트에 SAPI 축을
 더했다.

2. 매니페스트 삭제 실패 기록 — PackageManifestCacheHelper::clear 가 지우지 못한 파일의
 경로를 돌려주고, spawn 직전 호출부가 업그레이드 로그·콘솔에 경고로 남긴다. 권한·소유권
 불일치면 자식의 자가 치유도 같은 이유로 실패해 증상은 제보와 같은 「Class not found」 인데,
 이 경고가 원인이 권한이라는 유일한 흔적이다.

3. 테스트 격리 — tests/bootstrap.php 가 APP_PACKAGES_CACHE/APP_SERVICES_CACHE 를 테스트
 전용 경로로 돌린다. proc_open 으로 자식을 띄우는 기존 테스트 2종의 자식이 개발 클론의
 실제 bootstrap/cache 매니페스트를 지우고 다시 쓰던 것(stat 실측)을 부모·자식 함께 막는다.

관리자 [시스템 최적화] 경로(withPreservedContainer 파사드 복원의 미실측 형제 호출처)는
임시 설치본에서 API 로 실측했다 — 200, 설정·라우트 캐시 재생성, 후속 요청 200, 로그 오류 0.

4. 트러블슈팅 사례 ↔ 회귀 테스트 앵커 계약 — 신규 사례는 헤딩에 <!-- case:{영역}-{번호} -->
 앵커를 달고 같은 문자열을 그 사례를 잠그는 회귀 테스트에도 남겨야 한다. 사례 번호는
 문서마다 1부터 재시작하고 병합으로 중복되므로(이번 리베이스에서도 우리 사례가 develop 과
 같은 29 였다가 31 로 밀렸다), 개수만 대조하면 다른 사례를 덮는 테스트도 초록이 된다.

 그런데 판정기 check-troubleshooting-test-coverage.cjs 를 부르는 지점이 저장소에 하나도
 없었다 — 스크립트 자체 주석에만 실행법이 적혀 있어 아무도 부르지 않으면 영원히 돌지
 않았고, 그 사이 위반이 9건 쌓였다(backend 26~31, cache 17~19). 돌지 않는 대조는 아무것도
 잠그지 못하므로 위반 해소와 실행 지점 부여를 함께 한다.

 9건 전부에 앵커를 부착하고(각 사례가 선언한 회귀 테스트 중 가장 구체적인 파일에 배치,
 한 파일이 두 사례에 선언된 경우는 갈라 배치), stop-guard 7.2 에 앵커 계약 + 미커버
 baseline ratchet 두 축으로 등록했다. 트러블슈팅 사례 추가 프로토콜에 6단계를
 더하고 coverage 에 troubleshooting-case-anchor-contract(manual-only, 전용 판정기 위임)를
 등재했다. 판정기 종료코드 1 → 0, 미커버 건수는 전 문서 baseline 그대로다.
2026-09-08 10:42:19 +09:00

157 lines
6.7 KiB
PHP

<?php
namespace Tests\Unit\Extension;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;
/**
* [case:cache-17] 템플릿 업데이트 성공 cleanup 의 config 캐시 무효화 구조 테스트 (#588, 공개 #119)
*
* `template.config.{identifier}` 는 버전 접미사 없는 고정 키라 캐시 버전 bump 로
* 무효화되지 않는다. updateTemplate() 성공 cleanup 이 clearTemplateCache() 를
* 직접 호출하지 않으면, 비활성 업데이트·레이아웃 무변경 업데이트 경로에서 공개
* config.json 이 TTL(1시간) 동안 이전 manifest 를 계속 서빙한다.
*
* 실제 updateTemplate() 통합 실행은 업데이트 소스(_pending/신버전) 픽스처가 필요해
* 비용이 크므로, clearTemplateCache() 의 키 삭제는 TemplateCacheManagementTest 가
* 동작으로 검증하고 본 테스트는 호출 존재를 소스 구조로 고정한다
* (선례: ExtensionUpdateStatusCacheSymmetryTest).
*/
class TemplateUpdateConfigCacheInvalidationTest extends TestCase
{
/**
* @scenario cache_state=warm_current,lifecycle_trigger=template_update,template_type=user
*
* @effects template_update_invalidates_public_config_cache
*/
#[Test]
public function update_template_cleanup_calls_clear_template_cache(): void
{
$body = $this->methodSource(
base_path('app/Extension/TemplateManager.php'),
'updateTemplate'
);
$this->assertStringContainsString(
'$this->clearTemplateCache(',
$body,
'updateTemplate() 성공 cleanup 이 clearTemplateCache() 를 호출해야 합니다 — '
.'누락 시 공개 config.json 이 캐시 TTL 동안 이전 manifest 로 응답됩니다.'
);
}
/**
* 업데이트 tail 은 활성/비활성과 무관하게 **무조건** 캐시 버전을 올린다 (#651 F1).
*
* 종전에는 `if ($previousStatus !== ExtensionStatus::Active->value)` 가드 안에서만 올렸다.
* 활성 템플릿은 `refreshTemplateLayouts()` 의 조건부 bump(레이아웃 변경 건수 > 0)에
* 위임됐는데, 그 조건은 게시 입력 중 레이아웃 축만 보므로 lang/routes/dist 만 바뀐
* 릴리스는 정적 게시본이 immutable 로 stale 하게 남았다. 모듈·플러그인 update 는
* 무조건 bump 다 — 템플릿만 다를 이유가 없다.
*
* @scenario cache_state=warm_current,lifecycle_trigger=template_update,template_type=admin
*
* @effects template_update_always_bumps_cache_version
*/
#[Test]
public function update_template_tail_bumps_cache_version_unconditionally(): void
{
$body = $this->methodSource(
base_path('app/Extension/TemplateManager.php'),
'updateTemplate'
);
$this->assertStringNotContainsString(
'if ($previousStatus !== ExtensionStatus::Active->value)',
$body,
'updateTemplate() 의 bump 가 previousStatus 가드 안에 있으면 활성 템플릿의 lang/routes/dist 변경이 게시본에 반영되지 않습니다.'
);
// 성공 tail + 실패 catch 두 자리 — 최소 2회
$this->assertGreaterThanOrEqual(
2,
substr_count($body, '$this->incrementExtensionCacheVersion();'),
'updateTemplate() 는 성공 tail 과 실패 복원 경로 양쪽에서 캐시 버전을 올려야 합니다.'
);
}
/**
* 실패·복원 경로도 캐시 버전을 올린다 (#651 F2).
*
* 백업 복원이 실패했거나 부분 반영된 디스크가 남을 수 있어, 게시본을 새 버전으로 다시
* 굽는 쪽이 옛 게시본을 그대로 두는 쪽보다 안전하다. bump 실패는 원래 예외를 가리지
* 않도록 삼킨다.
*
* @scenario cache_state=warm_current,lifecycle_trigger=template_update,template_type=user
*
* @effects template_update_failure_bumps_after_restore
*/
#[Test]
public function update_template_failure_path_bumps_after_restore(): void
{
$body = $this->methodSource(
base_path('app/Extension/TemplateManager.php'),
'updateTemplate'
);
$catchAt = strrpos($body, 'catch (\Throwable $e)');
$this->assertNotFalse($catchAt, 'updateTemplate() 의 최외곽 catch 를 찾지 못했습니다.');
$catchBody = substr($body, $catchAt);
$restoreAt = strpos($catchBody, "'status' => \$previousStatus");
$bumpAt = strpos($catchBody, '$this->incrementExtensionCacheVersion();');
$throwAt = strpos($catchBody, 'throw new \RuntimeException(');
$this->assertNotFalse($restoreAt, '실패 경로의 상태 복원 코드를 찾지 못했습니다.');
$this->assertNotFalse($bumpAt, '실패 경로에 캐시 버전 bump 가 없습니다 — 부분 반영된 디스크가 옛 게시본과 함께 남습니다.');
$this->assertNotFalse($throwAt, '실패 경로의 재던지기를 찾지 못했습니다.');
$this->assertTrue($restoreAt < $bumpAt && $bumpAt < $throwAt, 'bump 는 상태 복원 뒤, 재던지기 앞에 있어야 합니다.');
}
/**
* 대상 메서드 본문을 주석 제거 후 반환합니다.
*
* 주석을 남겨 두면 설명 문장에 들어 있는 호출 표기가 검사를 통과시킨다.
*
* @param string $path 대상 파일 절대 경로
* @param string $method 대상 메서드명
* @return string 주석이 제거된 메서드 본문
*/
private function methodSource(string $path, string $method): string
{
$this->assertFileExists($path);
$stripped = '';
foreach (token_get_all(file_get_contents($path)) as $token) {
if (is_array($token) && in_array($token[0], [T_COMMENT, T_DOC_COMMENT], true)) {
continue;
}
$stripped .= is_array($token) ? $token[1] : $token;
}
$start = strpos($stripped, "public function {$method}(");
$this->assertNotFalse($start, "{$path} 에서 {$method}() 메서드를 찾지 못했습니다.");
// 중괄호 균형으로 메서드 본문 끝을 찾는다
$open = strpos($stripped, '{', $start);
$depth = 0;
$length = strlen($stripped);
for ($i = $open; $i < $length; $i++) {
if ($stripped[$i] === '{') {
$depth++;
} elseif ($stripped[$i] === '}') {
$depth--;
if ($depth === 0) {
return substr($stripped, $open, $i - $open + 1);
}
}
}
$this->fail("{$path} 의 {$method}() 본문 끝을 찾지 못했습니다.");
}
}