Files
Gnuboard7/tests/Unit/Services/ExtensionBundleServiceTest.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

1030 lines
42 KiB
PHP

<?php
namespace Tests\Unit\Services;
use App\Extension\ModuleManager;
use App\Extension\PluginManager;
use App\Services\ExtensionBundleService;
use Illuminate\Contracts\Cache\Lock;
use Illuminate\Contracts\Cache\LockTimeoutException;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\File;
use Mockery;
use Tests\TestCase;
/**
* [case:backend-30] ExtensionBundleService 단위 테스트
*
* 활성 확장 IIFE/CSS 의 priority 정렬, `\n;\n` 구분자 병합, sourceMappingURL
* 처리, 확장별 fault tolerance, 캐시 파일 생성/정리를 검증한다.
*/
class ExtensionBundleServiceTest extends TestCase
{
private string $fixtureDir;
private ModuleManager $moduleManager;
private PluginManager $pluginManager;
protected function setUp(): void
{
parent::setUp();
$this->fixtureDir = storage_path('framework/testing/ext-bundle-fixtures');
File::ensureDirectoryExists($this->fixtureDir);
$this->moduleManager = Mockery::mock(ModuleManager::class);
$this->pluginManager = Mockery::mock(PluginManager::class);
// 번들 캐시 디렉토리 초기화 (테스트 격리)
$bundleDir = storage_path('app/ext-bundles');
if (is_dir($bundleDir)) {
foreach (glob($bundleDir.'/*.{js,css}', GLOB_BRACE) as $f) {
@unlink($f);
}
}
}
protected function tearDown(): void
{
if (is_dir($this->fixtureDir)) {
foreach (glob($this->fixtureDir.'/*') as $f) {
@unlink($f);
}
@rmdir($this->fixtureDir);
}
$bundleDir = storage_path('app/ext-bundles');
if (is_dir($bundleDir)) {
foreach (glob($bundleDir.'/*.{js,css}', GLOB_BRACE) as $f) {
@unlink($f);
}
}
Mockery::close();
parent::tearDown();
}
/**
* 지정 내용으로 fixture 에셋 파일을 만들고 절대 경로를 반환한다.
*/
private function writeFixture(string $name, string $content): string
{
$path = $this->fixtureDir.'/'.$name;
File::put($path, $content);
return $path;
}
/**
* hasAssets/getAssetLoadingConfig/getBuiltAssetAbsolutePaths/getIdentifier 를
* 노출하는 가짜 확장 인스턴스를 만든다.
*/
private function fakeExtension(string $identifier, int $priority, ?string $jsPath, ?string $cssPath, string $strategy = 'global', ?string $cssRelPath = null): object
{
$ext = Mockery::mock();
$ext->shouldReceive('hasAssets')->andReturn(true);
$ext->shouldReceive('getIdentifier')->andReturn($identifier);
$ext->shouldReceive('getAssetLoadingConfig')->andReturn([
'strategy' => $strategy,
'priority' => $priority,
'dependencies' => [],
]);
$paths = [];
if ($jsPath !== null) {
$paths['js'] = $jsPath;
}
if ($cssPath !== null) {
$paths['css'] = $cssPath;
}
$ext->shouldReceive('getBuiltAssetAbsolutePaths')->andReturn($paths);
// 확장 루트 기준 상대 경로 — CSS 상대 참조 해석의 기준점
$relative = [];
if ($cssRelPath !== null) {
$relative['css'] = $cssRelPath;
}
$ext->shouldReceive('getBuiltAssetPaths')->andReturn($relative);
return $ext;
}
/**
* 에셋 **매니페스트 선언**(`getAssets()`)을 노출하는 가짜 확장 인스턴스를 만든다.
*
* 산출물 경로(`getBuiltAssetAbsolutePaths`)는 기본적으로 존재하지 않는 파일을 가리킨다 —
* "선언은 있는데 산출물이 없다"(dist 소실) 상태를 그대로 재현하기 위해서다.
*
* 선언 축 게터(`getDeclaredAssetAbsolutePaths`)는 파일 존재와 무관하게 선언된 kind 마다
* 경로를 돌려준다. `$declaredPaths` 로 그 경로를 지정하면 실제로 만들어 둔 fixture 파일을
* 가리키게 해 "존재하되 비어 있음" 상태를 만들 수 있다.
*
* @param string $identifier 확장 식별자
* @param int $priority 로딩 우선순위
* @param array<string, mixed> $assets 매니페스트 assets 선언
* @param string $strategy 로딩 전략
* @param array<string, string>|null $declaredPaths kind => 절대 경로 (미지정 시 fixtureDir 의 부재 경로)
*/
private function fakeExtensionWithAssets(
string $identifier,
int $priority,
array $assets,
string $strategy = 'global',
?array $declaredPaths = null,
?array $builtPaths = null
): object {
$ext = Mockery::mock();
$ext->shouldReceive('hasAssets')->andReturn($assets !== []);
$ext->shouldReceive('getIdentifier')->andReturn($identifier);
$ext->shouldReceive('getAssetLoadingConfig')->andReturn([
'strategy' => $strategy,
'priority' => $priority,
'dependencies' => [],
]);
$ext->shouldReceive('getAssets')->andReturn($assets);
// 실제 확장은 존재하는 산출물만 built 경로로 돌려준다(getBuiltAssetPaths 가 file_exists 로 거른다).
// 기본값은 "산출물 없음" 상태를 흉내 내는 부재 경로다 — 존재하는 산출물을 흉내 내려면 $builtPaths 로 지정한다.
$ext->shouldReceive('getBuiltAssetAbsolutePaths')->andReturn(
$builtPaths ?? array_map(fn () => $this->fixtureDir.'/missing-'.$identifier.'.out', $assets)
);
$ext->shouldReceive('getBuiltAssetPaths')->andReturn(
array_map(fn () => 'dist/css/module.css', $assets)
);
$declared = $declaredPaths ?? array_combine(
array_keys($assets),
array_map(fn ($kind) => $this->fixtureDir.'/declared-'.$identifier.'.'.$kind, array_keys($assets))
);
$ext->shouldReceive('getDeclaredAssetAbsolutePaths')->andReturn($declared);
return $ext;
}
private function service(): ExtensionBundleService
{
return new ExtensionBundleService($this->moduleManager, $this->pluginManager);
}
/**
* 선언 수는 **매니페스트 기준**이다 — 산출물 파일 존재 여부와 무관하다 (E2).
*
* 이 값이 "실제로 병합된 확장 수" 로 계산되면 dist 가 통째로 비어 있을 때
* `기대 0 = 결과 0` 이 되어 장애가 정상(빈 200)으로 위장된다. 실측 A/B: dist 를
* 비우면 수정 전 `modules/bundle/js` 가 빈 200, 수정 후 503 이다.
*
* @effects asset_declaration_count_reads_manifest_not_built_files
*/
public function test_counts_asset_declaring_extensions_from_manifest_not_built_output(): void
{
$this->moduleManager->shouldReceive('getActiveModules')->andReturn([
'ext-js' => $this->fakeExtensionWithAssets('ext-js', 10, ['js' => ['output' => 'dist/js/ext-js.iife.js']]),
'ext-both' => $this->fakeExtensionWithAssets('ext-both', 20, [
'js' => ['output' => 'dist/js/ext-both.iife.js'],
'css' => ['output' => 'dist/css/ext-both.css'],
]),
'ext-none' => $this->fakeExtensionWithAssets('ext-none', 30, []),
// 병합 대상 모집단과 같은 필터 — global 전략이 아니면 세지 않는다
'ext-layout' => $this->fakeExtensionWithAssets('ext-layout', 40, [
'js' => ['output' => 'dist/js/ext-layout.iife.js'],
], 'layout'),
]);
$service = $this->service();
$this->assertSame(2, $service->countAssetDeclaringExtensions('module', 'js'));
$this->assertSame(1, $service->countAssetDeclaringExtensions('module', 'css'));
// 산출물은 하나도 존재하지 않는다 → 병합 결과는 빈 문자열.
// 이 값은 **선언 축**이다 — 컨트롤러의 503 판정은 소실 축(findMissingDeclaredAssets)이
// 한다. 선언 > 0 그 자체는 장애 조건이 아니다(존재하되 비어 있으면 정상 빈 200).
$this->assertSame('', $service->getBundleFilePath('module', 'js', 12345));
}
/**
* 실행 순서는 오직 manifest `loading.priority` 오름차순이다 — 확장 이름을 지목하는
* 분기를 두지 않는다.
*
* @effects ordered_by_priority_ascending_only_no_name_hardcode
*/
public function test_orders_global_assets_by_priority_ascending(): void
{
$a = $this->writeFixture('a.js', '(function(){})()');
$b = $this->writeFixture('b.js', '(function(){})()');
$c = $this->writeFixture('c.js', '(function(){})()');
$this->moduleManager->shouldReceive('getActiveModules')->andReturn([
'ext-c' => $this->fakeExtension('ext-c', 30, $c, null),
'ext-a' => $this->fakeExtension('ext-a', 10, $a, null),
'ext-b' => $this->fakeExtension('ext-b', 20, $b, null),
]);
$ordered = $this->service()->getOrderedGlobalAssetPaths('module');
$this->assertSame(['ext-a', 'ext-b', 'ext-c'], array_keys($ordered));
}
/**
* global 전략이 아닌 확장은 번들 모집단이 아니다 (레이아웃/지연 로딩이 따로 처리한다).
*
* @effects non_global_strategy_excluded_from_bundle
*/
public function test_skips_non_global_strategy(): void
{
$g = $this->writeFixture('g.js', '(function(){})()');
$l = $this->writeFixture('l.js', '(function(){})()');
$this->moduleManager->shouldReceive('getActiveModules')->andReturn([
'ext-global' => $this->fakeExtension('ext-global', 100, $g, null, 'global'),
'ext-layout' => $this->fakeExtension('ext-layout', 100, $l, null, 'layout'),
]);
$ordered = $this->service()->getOrderedGlobalAssetPaths('module');
$this->assertArrayHasKey('ext-global', $ordered);
$this->assertArrayNotHasKey('ext-layout', $ordered);
}
/**
* IIFE 사이는 `\n;\n` 로 잇는다 — 구분자가 없으면 ASI 경계가 깨져 번들 전체가 죽는다.
*
* @effects js_iife_joined_with_semicolon_newline_separator
*/
public function test_js_bundle_joins_iife_with_semicolon_newline_separator(): void
{
// 세미콜론 없이 끝나는 IIFE 2개 (ecommerce 형태)
$a = $this->writeFixture('a.js', '(function(){window.A=1})()');
$b = $this->writeFixture('b.js', '(function(){window.B=2})()');
$this->moduleManager->shouldReceive('getActiveModules')->andReturn([
'ext-a' => $this->fakeExtension('ext-a', 10, $a, null),
'ext-b' => $this->fakeExtension('ext-b', 20, $b, null),
]);
$js = $this->service()->buildJsBundle('module');
// 두 IIFE 가 모두 포함되고 `\n;\n` 구분자로 구분
$this->assertStringContainsString('window.A=1', $js);
$this->assertStringContainsString('window.B=2', $js);
$this->assertStringContainsString("\n;\n", $js);
// priority 순서 (a 가 b 보다 먼저)
$this->assertLessThan(strpos($js, 'window.B=2'), strpos($js, 'window.A=1'));
}
/**
* @effects prod_strips_source_mapping_url
*/
public function test_prod_strips_source_mapping_url(): void
{
$this->app['env'] = 'production';
app()->detectEnvironment(fn () => 'production');
$a = $this->writeFixture('a.js', "(function(){})()\n//# sourceMappingURL=a.js.map");
$this->moduleManager->shouldReceive('getActiveModules')->andReturn([
'ext-a' => $this->fakeExtension('ext-a', 10, $a, null),
]);
$js = $this->service()->buildJsBundle('module');
$this->assertStringNotContainsString('sourceMappingURL', $js);
}
/**
* @effects dev_rewrites_source_mapping_url_to_asset_serving_path
*/
public function test_dev_rewrites_source_mapping_url_to_asset_serving_path(): void
{
// 자산 URL 표기(경로형 vs `?file=` 쿼리형)는 사이트 설정이 정한다. 이 테스트가 보려는
// 것은 "개발 환경에서 소스맵 URL 이 에셋 서빙 주소로 재작성되는가" 이므로, 개발 사이트가
// 어느 모드를 쓰든 결과가 달라지지 않도록 경로형으로 고정한다.
config(['g7_settings.core.general.asset_url_mode' => 'extension']);
// 기본 testing 환경 = 비프로덕션 → dev rewrite 경로
$a = $this->writeFixture('a.js', "(function(){})()\n//# sourceMappingURL=a.js.map");
$this->moduleManager->shouldReceive('getActiveModules')->andReturn([
'ext-a' => $this->fakeExtension('ext-a', 10, $a, null),
]);
$js = $this->service()->buildJsBundle('module');
$this->assertStringContainsString('sourceMappingURL=/api/modules/assets/ext-a/dist/js/a.js.map', $js);
}
public function test_prod_strips_css_source_mapping_url(): void
{
$this->app['env'] = 'production';
app()->detectEnvironment(fn () => 'production');
// CSS 는 JS 와 주석 문법이 달라 블록 주석으로 맵을 참조한다.
$css = $this->writeFixture('a.css', ".a{color:red}\n/*# sourceMappingURL=a.css.map */");
$this->moduleManager->shouldReceive('getActiveModules')->andReturn([
'ext-a' => $this->fakeExtension('ext-a', 10, null, $css),
]);
$bundle = $this->service()->buildCssBundle('module');
$this->assertStringNotContainsString('sourceMappingURL', $bundle);
// 스타일 본문은 보존되어야 한다 (주석만 제거)
$this->assertStringContainsString('.a{color:red}', $bundle);
}
public function test_dev_keeps_css_source_mapping_url(): void
{
// 기본 testing 환경 = 비프로덕션 → 개발 디버깅을 위해 원본 유지
$css = $this->writeFixture('a.css', ".a{color:red}\n/*# sourceMappingURL=a.css.map */");
$this->moduleManager->shouldReceive('getActiveModules')->andReturn([
'ext-a' => $this->fakeExtension('ext-a', 10, null, $css),
]);
$bundle = $this->service()->buildCssBundle('module');
$this->assertStringContainsString('sourceMappingURL', $bundle);
}
/**
* 한 확장의 읽기 실패가 번들 전체를 소실시키지 않는다 — 그 확장만 빠지고 나머지는 병합된다.
*
* @effects per_extension_fault_tolerance_skips_missing_file_keeps_rest
*/
public function test_per_extension_fault_tolerance_skips_missing_file(): void
{
$good = $this->writeFixture('good.js', '(function(){window.GOOD=1})()');
$missing = $this->fixtureDir.'/does-not-exist.js';
$this->moduleManager->shouldReceive('getActiveModules')->andReturn([
'ext-missing' => $this->fakeExtension('ext-missing', 10, $missing, null),
'ext-good' => $this->fakeExtension('ext-good', 20, $good, null),
]);
$js = $this->service()->buildJsBundle('module');
// 없는 파일은 skip, 정상 확장은 여전히 포함 (번들 전체 붕괴 안 함)
$this->assertStringContainsString('window.GOOD=1', $js);
}
/**
* 상대 참조를 가진 CSS 는 **제외되지 않고 치환되어** 병합된다.
*
* 종전 계약은 그 확장을 번들에서 통째로 제외하는 것이었고 주석은 "개별 폴백 유지" 라고
* 적었지만, 번들 URL 이 내려오면 프론트는 개별 로딩을 아예 타지 않는다
* (TemplateApp.loadExtensionAssets). 즉 제외 = 그 확장 스타일이 하나도 적용되지 않음
* 이었고, 오류도 로그 흔적도 화면 경고도 남지 않았다.
*
* @effects css_with_relative_url_rewritten_not_excluded
*/
public function test_css_with_relative_url_is_rewritten_not_excluded(): void
{
$safe = $this->writeFixture('safe.css', '.a{color:red}');
$relative = $this->writeFixture('rel.css', '.b{background:url(./img/x.png)}');
$this->moduleManager->shouldReceive('getActiveModules')->andReturn([
'ext-safe' => $this->fakeExtension('ext-safe', 10, null, $safe, cssRelPath: 'dist/css/module.css'),
'ext-rel' => $this->fakeExtension('ext-rel', 20, null, $relative, cssRelPath: 'dist/css/module.css'),
]);
$css = $this->service()->buildCssBundle('module');
// 두 확장 모두 병합에 남는다 — 제외가 사라졌다는 것이 이 축의 핵심이다.
$this->assertStringContainsString('.a{color:red}', $css);
$this->assertStringContainsString('.b{background:', $css);
// 상대 참조는 그대로 나가지 않는다 (그 주소는 병합본 기준으로 풀려 404 가 된다).
$this->assertStringNotContainsString('url(./img/x.png)', $css);
// 치환 결과는 그 확장의 절대 자산 URL 이며, CSS 가 놓인 디렉토리 기준으로 풀린다.
$this->assertStringContainsString('/api/modules/assets/ext-rel', $css);
$this->assertStringContainsString('dist/css/img/x.png', rawurldecode($css));
}
/**
* 절대·루트상대·data URI 참조는 병합에서도 손대지 않는다.
*/
public function test_css_bundle_leaves_absolute_references_untouched(): void
{
$path = $this->writeFixture('abs.css',
'.a{background:url(https://cdn.example.com/x.png)}'
.'.b{background:url(/build/ext/1/y.png)}'
.'.c{background:url(data:image/gif;base64,AA==)}'
);
$this->moduleManager->shouldReceive('getActiveModules')->andReturn([
'ext-abs' => $this->fakeExtension('ext-abs', 10, null, $path, cssRelPath: 'dist/css/module.css'),
]);
$css = $this->service()->buildCssBundle('module');
$this->assertStringContainsString('url(https://cdn.example.com/x.png)', $css);
$this->assertStringContainsString('url(/build/ext/1/y.png)', $css);
$this->assertStringContainsString('url(data:image/gif;base64,AA==)', $css);
}
/**
* @effects empty_bundle_returns_empty_path_and_empty_ok_response
*/
public function test_empty_bundle_returns_empty_path(): void
{
$this->moduleManager->shouldReceive('getActiveModules')->andReturn([]);
$path = $this->service()->getBundleFilePath('module', 'js', 12345);
$this->assertSame('', $path);
}
/**
* @effects prod_writes_versioned_cache_file_and_reuses_on_same_version
*/
public function test_prod_writes_versioned_cache_file_and_reuses_it(): void
{
$this->app['env'] = 'production';
app()->detectEnvironment(fn () => 'production');
$a = $this->writeFixture('a.js', '(function(){window.A=1})()');
// 캐시 적중 경로는 활성 확장을 다시 열거하지 않아야 하므로 정확히 1회로 조인다
$this->moduleManager->shouldReceive('getActiveModules')->once()->andReturn([
'ext-a' => $this->fakeExtension('ext-a', 10, $a, null),
]);
$svc = $this->service();
$path1 = $svc->getBundleFilePath('module', 'js', 999);
$this->assertNotSame('', $path1);
$this->assertFileExists($path1);
$this->assertStringContainsString('module.999.js', $path1);
$content1 = (string) file_get_contents($path1);
// 같은 version 재요청 → 동일 파일 (캐시 히트)
$path2 = $svc->getBundleFilePath('module', 'js', 999);
$this->assertSame($path1, $path2);
$this->assertSame($content1, (string) file_get_contents($path2));
}
/**
* 비프로덕션은 같은 version 이어도 매 요청 다시 병합한다 (캐시 재사용 없음).
*
* 프로덕션은 같은 version 캐시가 있으면 그대로 재사용하지만, 비프로덕션에서 그러면
* 개발 중 rebuild 가 반영되지 않는다. 소스를 바꿨는데 화면이 그대로인 상태는 오류도
* 로그도 남지 않아 원인을 짚을 수 없다.
*
* @effects non_production_rebuilds_every_request_no_disk_cache
*/
public function test_non_production_rebuilds_every_request_without_cache_reuse(): void
{
$this->assertFalse(app()->environment('production'));
$a = $this->writeFixture('nonprod.js', '(function(){window.A=1})()');
$this->moduleManager->shouldReceive('getActiveModules')->andReturn([
'ext-a' => $this->fakeExtension('ext-a', 10, $a, null),
]);
$svc = $this->service();
$path1 = $svc->getBundleFilePath('module', 'js', 4242);
$this->assertStringContainsString('window.A=1', (string) file_get_contents($path1));
// 소스를 바꾸고 **같은 version** 으로 다시 요청 → 재병합되어야 한다
File::put($a, '(function(){window.A=2})()');
$path2 = $svc->getBundleFilePath('module', 'js', 4242);
$this->assertSame($path1, $path2);
$this->assertStringContainsString('window.A=2', (string) file_get_contents($path2));
@unlink($path1);
}
/**
* @effects cleanup_removes_stale_version_bundles_only
*/
public function test_cleanup_removes_stale_version_bundles_only(): void
{
$bundleDir = storage_path('app/ext-bundles');
File::ensureDirectoryExists($bundleDir);
File::put($bundleDir.'/module.100.js', 'old');
File::put($bundleDir.'/module.200.js', 'current');
File::put($bundleDir.'/plugin.100.css', 'old');
$this->moduleManager->shouldReceive('getActiveModules')->andReturn([]);
$deleted = $this->service()->cleanupStaleBundles(200);
// version 200 외 파일만 삭제 (module.100.js, plugin.100.css = 2건)
$this->assertSame(2, $deleted);
$this->assertFileExists($bundleDir.'/module.200.js');
$this->assertFileDoesNotExist($bundleDir.'/module.100.js');
$this->assertFileDoesNotExist($bundleDir.'/plugin.100.css');
}
/**
* @effects clear_bundles_by_type_scopes_to_prefix
*/
public function test_clear_bundles_by_type(): void
{
$bundleDir = storage_path('app/ext-bundles');
File::ensureDirectoryExists($bundleDir);
File::put($bundleDir.'/module.100.js', 'm');
File::put($bundleDir.'/plugin.100.js', 'p');
$deleted = $this->service()->clearBundles('module');
$this->assertSame(1, $deleted);
$this->assertFileDoesNotExist($bundleDir.'/module.100.js');
$this->assertFileExists($bundleDir.'/plugin.100.js');
}
/**
* clearBundles 는 **현재 버전을 보존**한다 (E3).
*
* 현재 버전까지 지우면 같은 순간 서빙 중인 웹 요청이 "존재함" 판정 직후
* `filemtime()` 에서 500 을 낸다(bump 직후 TOCTOU). `cleanupStaleBundles` 와
* 정책이 갈라져 있던 것이 원인이었다.
*
* @effects clear_bundles_preserves_current_version
*/
public function test_clear_bundles_preserves_current_version(): void
{
$bundleDir = storage_path('app/ext-bundles');
File::ensureDirectoryExists($bundleDir);
$current = $this->service()->getCurrentVersion();
File::put($bundleDir."/module.{$current}.js", 'current');
File::put($bundleDir.'/module.100.js', 'old');
$deleted = $this->service()->clearBundles('module');
$this->assertSame(1, $deleted);
$this->assertFileExists(
$bundleDir."/module.{$current}.js",
'현재 버전이 삭제됐다 — 서빙 중인 요청이 filemtime 에서 500 을 낸다'
);
$this->assertFileDoesNotExist($bundleDir.'/module.100.js');
}
/**
* GC 는 원자적 쓰기의 임시 파일(`*.tmp.{pid}`)도 정리한다 — 단 나이 가드를 지킨다 (E4).
*
* 종전에는 이 파일명이 번들 파일 패턴에 맞지 않아 GC 대상에서 통째로 빠졌고,
* rename 이 실패한 만큼 영구 잔존했다(실측 560개).
*
* @effects cleanup_removes_stale_atomic_write_temp_files
*/
public function test_cleanup_removes_stale_temp_bundle_files_only(): void
{
$bundleDir = storage_path('app/ext-bundles');
File::ensureDirectoryExists($bundleDir);
$fresh = $bundleDir.'/module.200.js.tmp.11111';
$stale = $bundleDir.'/module.200.js.tmp.22222';
File::put($fresh, 'writing now');
File::put($stale, 'orphan');
touch($stale, time() - 3600);
clearstatcache();
$this->moduleManager->shouldReceive('getActiveModules')->andReturn([]);
try {
$deleted = $this->service()->cleanupStaleBundles(200);
$this->assertFileExists($fresh, '진행 중인 쓰기의 임시 파일이 삭제됐다');
$this->assertFileDoesNotExist($stale, '고아 임시 파일이 정리되지 않았다 — 영구 누적된다');
$this->assertSame(1, $deleted);
} finally {
// 번들 디렉토리는 테스트 간 공유된다 — 남기면 형제 케이스의 삭제 건수가 틀어진다.
@unlink($fresh);
@unlink($stale);
}
}
/**
* @effects plugin_bundle_gdpr_first_when_priority_lowest
*/
public function test_plugin_bundle_orders_by_priority_gdpr_first_when_lowest(): void
{
// gdpr 가 priority 50 으로 최상단(제약 1 회귀 가드) — 이름 하드코딩 아닌 선언 결과
$gdpr = $this->writeFixture('gdpr.js', '(function(){window.GDPR=1})()');
$other = $this->writeFixture('other.js', '(function(){window.OTHER=1})()');
$this->pluginManager->shouldReceive('getActivePlugins')->andReturn([
'sirsoft-other' => $this->fakeExtension('sirsoft-other', 100, $other, null),
'sirsoft-gdpr' => $this->fakeExtension('sirsoft-gdpr', 50, $gdpr, null),
]);
$ordered = $this->service()->getOrderedGlobalAssetPaths('plugin');
$this->assertSame('sirsoft-gdpr', array_key_first($ordered));
$js = $this->service()->buildJsBundle('plugin');
$this->assertLessThan(strpos($js, 'window.OTHER=1'), strpos($js, 'window.GDPR=1'));
}
/**
* 선언한 산출물이 **존재하되 0바이트**면 소실 목록에 넣지 않는다.
*
* 스타일 소스가 자리표시 주석뿐인 확장은 0바이트 CSS 를 내보내는 정당한 상태다.
* 이것을 소실로 세면 그 확장만 설치된 기본 구성이 통째로 503 이 된다.
*
* @effects empty_result_with_present_empty_artifacts_returns_200
*/
public function test_find_missing_declared_assets_ignores_present_zero_byte_files(): void
{
$cssPath = $this->writeFixture('present-empty.css', '');
$this->moduleManager->shouldReceive('getActiveModules')->andReturn([
'ext-empty-css' => $this->fakeExtensionWithAssets(
'ext-empty-css',
100,
['css' => ['output' => 'dist/css/module.css']],
'global',
['css' => $cssPath]
),
]);
$service = $this->service();
$this->assertSame(0, filesize($cssPath));
$this->assertSame([], $service->findMissingDeclaredAssets('module', 'css'));
// 선언 축은 그대로 1 — 두 축이 다른 것을 잰다
$this->assertSame(1, $service->countAssetDeclaringExtensions('module', 'css'));
// 병합 결과는 빈 문자열이지만 장애가 아니다
$this->assertSame('', $service->buildCssBundle('module'));
}
/**
* 선언한 산출물이 **없으면** 그 절대 경로를 소실 목록으로 돌려준다.
*
* non-global 전략 확장은 병합 모집단이 아니므로 소실 판정에서도 제외된다 —
* 두 판정이 같은 모집단을 써야 한쪽만 장애로 보는 어긋남이 생기지 않는다.
*
* @effects empty_result_with_missing_declared_artifact_returns_503
*/
public function test_find_missing_declared_assets_lists_absent_files(): void
{
$absent = $this->fixtureDir.'/absent-module.css';
$absentLayout = $this->fixtureDir.'/absent-layout.css';
@unlink($absent);
@unlink($absentLayout);
$this->moduleManager->shouldReceive('getActiveModules')->andReturn([
'ext-gone' => $this->fakeExtensionWithAssets(
'ext-gone',
100,
['css' => ['output' => 'dist/css/module.css']],
'global',
['css' => $absent]
),
'ext-layout' => $this->fakeExtensionWithAssets(
'ext-layout',
100,
['css' => ['output' => 'dist/css/module.css']],
'layout',
['css' => $absentLayout]
),
]);
$missing = $this->service()->findMissingDeclaredAssets('module', 'css');
$this->assertSame([$absent], $missing);
$this->assertNotContains($absentLayout, $missing);
}
/**
* 플러그인 축도 같은 판정을 쓴다 — 모집단만 다르다.
*
* @effects bundle_decision_is_shared_across_extension_types
*/
public function test_find_missing_declared_assets_covers_plugins(): void
{
$absent = $this->fixtureDir.'/absent-plugin.js';
@unlink($absent);
$present = $this->writeFixture('present-plugin.js', '');
$this->pluginManager->shouldReceive('getActivePlugins')->andReturn([
'plg-gone' => $this->fakeExtensionWithAssets(
'plg-gone',
100,
['js' => ['output' => 'dist/js/plugin.iife.js']],
'global',
['js' => $absent]
),
'plg-empty' => $this->fakeExtensionWithAssets(
'plg-empty',
100,
['js' => ['output' => 'dist/js/plugin.iife.js']],
'global',
['js' => $present]
),
]);
$this->assertSame([$absent], $this->service()->findMissingDeclaredAssets('plugin', 'js'));
}
/**
* 프로덕션에서 같은 version 캐시가 있으면 **빌드도 원본 읽기도 하지 않는다**.
*
* 캐시 적중 판정이 빌드 뒤에 있으면 캐시가 있어도 요청마다 활성 확장을 열거하고
* 산출물 파일을 전부 읽는다. 응답은 정상 200 이라 타이밍 말고는 드러나는 증상이
* 없고, 원본이 사라진 순간에는 캐시가 멀쩡한데도 빈 경로가 반환되어 503 이 된다.
*
* @effects prod_cache_hit_skips_build_and_source_reads
*/
public function test_prod_cache_hit_returns_cached_path_without_enumerating_or_reading_sources(): void
{
$this->app['env'] = 'production';
app()->detectEnvironment(fn () => 'production');
$a = $this->writeFixture('cache-hit.js', '(function(){window.A=1})()');
$enumerations = 0;
$this->moduleManager->shouldReceive('getActiveModules')->andReturnUsing(function () use (&$enumerations, $a) {
$enumerations++;
return ['ext-a' => $this->fakeExtension('ext-a', 10, $a, null)];
});
$svc = $this->service();
$path1 = $svc->getBundleFilePath('module', 'js', 424242);
$this->assertNotSame('', $path1);
$this->assertFileExists($path1);
$this->assertSame(1, $enumerations);
$before = (string) file_get_contents($path1);
// 원본 소실 — 캐시가 있으므로 응답에 영향이 없어야 한다
@unlink($a);
$path2 = $svc->getBundleFilePath('module', 'js', 424242);
$this->assertSame($path1, $path2);
$this->assertSame(1, $enumerations, '캐시 적중 시 활성 확장을 다시 열거하면 안 된다');
$this->assertSame($before, (string) file_get_contents($path2));
}
/**
* 프로덕션은 같은 version 이면 원본이 바뀌어도 캐시를 그대로 서빙한다.
*
* 비프로덕션 거울 테스트(test_non_production_rebuilds_every_request_without_cache_reuse)와
* 짝을 이룬다 — 원본 교체는 version bump 로만 반영된다.
*
* @effects prod_cache_hit_skips_build_and_source_reads
*/
public function test_prod_cache_hit_does_not_rebuild_when_source_changes_on_same_version(): void
{
$this->app['env'] = 'production';
app()->detectEnvironment(fn () => 'production');
$a = $this->writeFixture('prod-stable.js', '(function(){window.A=1})()');
$this->moduleManager->shouldReceive('getActiveModules')->andReturn([
'ext-a' => $this->fakeExtension('ext-a', 10, $a, null),
]);
$svc = $this->service();
$path1 = $svc->getBundleFilePath('module', 'js', 424243);
$this->assertStringContainsString('window.A=1', (string) file_get_contents($path1));
File::put($a, '(function(){window.A=2})()');
$path2 = $svc->getBundleFilePath('module', 'js', 424243);
$this->assertSame($path1, $path2);
$this->assertStringContainsString('window.A=1', (string) file_get_contents($path2));
$this->assertStringNotContainsString('window.A=2', (string) file_get_contents($path2));
}
/**
* 캐시 미스는 같은 (type, kind, version) 잠금으로 1회 빌드에 수렴하고,
* 잠금 뒤에 캐시를 **다시 확인**한다.
*
* 다른 프로세스가 대기 중에 캐시를 완성했다면 이쪽은 빌드하지 않아야 한다.
*
* @effects prod_cache_miss_builds_once_under_lock_and_rechecks
*/
public function test_prod_cache_miss_serializes_concurrent_builds_and_rechecks_cache_inside_lock(): void
{
$this->app['env'] = 'production';
app()->detectEnvironment(fn () => 'production');
$bundleDir = storage_path('app/ext-bundles');
File::ensureDirectoryExists($bundleDir);
$cachePath = $bundleDir.'/module.424244.js';
@unlink($cachePath);
$enumerations = 0;
$this->moduleManager->shouldReceive('getActiveModules')->andReturnUsing(function () use (&$enumerations) {
$enumerations++;
return [];
});
$lock = Mockery::mock(Lock::class);
// 대기 중 다른 프로세스가 캐시를 완성한 상황
$lock->shouldReceive('block')->once()->andReturnUsing(function () use ($cachePath) {
File::put($cachePath, '(function(){window.OTHER=1})()');
return true;
});
$lock->shouldReceive('release')->once()->andReturn(true);
Cache::shouldReceive('lock')->once()->andReturn($lock);
$path = $this->service()->getBundleFilePath('module', 'js', 424244);
// 경로 구분자는 스토리지 드라이버가 정한다 — 같은 파일을 가리키는지만 본다
$this->assertSame(realpath($cachePath), realpath($path));
$this->assertSame(0, $enumerations, '잠금 뒤 재확인이 적중하면 빌드하지 않아야 한다');
@unlink($cachePath);
}
/**
* 잠금 대기가 초과되어도 실패로 바꾸지 않는다 — 잠금 없이 각자 빌드한다.
*
* @effects prod_build_lock_failure_falls_back_to_unlocked_build
*/
public function test_prod_cache_miss_builds_without_lock_when_lock_times_out(): void
{
$this->app['env'] = 'production';
app()->detectEnvironment(fn () => 'production');
$a = $this->writeFixture('lock-timeout.js', '(function(){window.A=1})()');
$this->moduleManager->shouldReceive('getActiveModules')->andReturn([
'ext-a' => $this->fakeExtension('ext-a', 10, $a, null),
]);
$lock = Mockery::mock(Lock::class);
$lock->shouldReceive('block')->once()->andThrow(new LockTimeoutException);
$lock->shouldReceive('release')->never();
Cache::shouldReceive('lock')->once()->andReturn($lock);
$path = $this->service()->getBundleFilePath('module', 'js', 424245);
$this->assertNotSame('', $path);
$this->assertFileExists($path);
$this->assertStringContainsString('window.A=1', (string) file_get_contents($path));
}
/**
* 캐시 저장소가 잠금을 제공하지 못해도(드라이버 미지원·권한 등) 빌드는 계속한다.
*
* @effects prod_build_lock_failure_falls_back_to_unlocked_build
*/
public function test_prod_cache_miss_builds_without_lock_when_store_cannot_lock(): void
{
$this->app['env'] = 'production';
app()->detectEnvironment(fn () => 'production');
$a = $this->writeFixture('lock-unavailable.js', '(function(){window.A=1})()');
$this->moduleManager->shouldReceive('getActiveModules')->andReturn([
'ext-a' => $this->fakeExtension('ext-a', 10, $a, null),
]);
Cache::shouldReceive('lock')->once()->andThrow(new \RuntimeException('lock unsupported'));
$path = $this->service()->getBundleFilePath('module', 'js', 424246);
$this->assertNotSame('', $path);
$this->assertFileExists($path);
$this->assertStringContainsString('window.A=1', (string) file_get_contents($path));
}
/**
* 선언 산출물이 **전부 존재하되 비어 있으면** 0바이트 캐시 파일을 만든다.
*
* 그래야 정적 게시 대상이 되어 브라우저가 웹서버에서 직접 받는다. 만들지 않으면
* 그 구성의 모든 페이지 로드가 PHP 를 경유하고, 요청마다 컨트롤러가 열거를 세 번
* 반복한다(경로 조회 → 재빌드 → 소실 판정).
*
* @effects prod_empty_result_with_present_artifacts_is_cached_as_zero_byte_file
*/
public function test_prod_caches_zero_byte_bundle_when_declared_artifacts_exist_but_empty(): void
{
$this->app['env'] = 'production';
app()->detectEnvironment(fn () => 'production');
$cssPath = $this->writeFixture('zero-byte.css', '');
$enumerations = 0;
$this->moduleManager->shouldReceive('getActiveModules')->andReturnUsing(function () use (&$enumerations, $cssPath) {
$enumerations++;
return [
'ext-empty-css' => $this->fakeExtensionWithAssets(
'ext-empty-css',
100,
['css' => ['output' => 'dist/css/module.css']],
'global',
['css' => $cssPath],
['css' => $cssPath]
),
];
});
$svc = $this->service();
$path = $svc->getBundleFilePath('module', 'css', 424247);
$this->assertNotSame('', $path);
$this->assertFileExists($path);
$this->assertSame(0, filesize($path));
$enumerationsAfterFirst = $enumerations;
$this->assertSame($path, $svc->getBundleFilePath('module', 'css', 424247));
$this->assertSame($enumerationsAfterFirst, $enumerations, '0바이트 캐시도 적중하면 열거하지 않는다');
}
/**
* 선언 산출물이 **소실**이면 캐시하지 않는다 — 503 계약을 보존한다.
*
* 0바이트 캐시가 소실 상태까지 가리면 배포 중 dist 가 빈 장애가 정상(빈 200)으로
* 위장된다.
*
* @effects prod_empty_result_with_missing_artifact_is_not_cached
*/
public function test_prod_does_not_cache_when_declared_artifact_missing(): void
{
$this->app['env'] = 'production';
app()->detectEnvironment(fn () => 'production');
$absent = $this->fixtureDir.'/absent-prod.css';
@unlink($absent);
$this->moduleManager->shouldReceive('getActiveModules')->andReturn([
'ext-gone' => $this->fakeExtensionWithAssets(
'ext-gone',
100,
['css' => ['output' => 'dist/css/module.css']],
'global',
['css' => $absent]
),
]);
$path = $this->service()->getBundleFilePath('module', 'css', 424248);
$this->assertSame('', $path);
$this->assertFileDoesNotExist(storage_path('app/ext-bundles/module.424248.css'));
}
/**
* 선언한 확장이 **하나도 없어도** 0바이트 캐시를 만든다(정적 게시 가능).
*
* @effects prod_empty_result_with_present_artifacts_is_cached_as_zero_byte_file
*/
public function test_prod_caches_zero_byte_bundle_when_nothing_declared(): void
{
$this->app['env'] = 'production';
app()->detectEnvironment(fn () => 'production');
$this->moduleManager->shouldReceive('getActiveModules')->andReturn([]);
$path = $this->service()->getBundleFilePath('module', 'css', 424249);
$this->assertNotSame('', $path);
$this->assertFileExists($path);
$this->assertSame(0, filesize($path));
}
/**
* 병합 단계에서 건너뛴 확장이 하나라도 있으면 캐시하지 않는다 — 파일은 존재·판독 가능한데
* 읽기·치환이 실패한 상태가 0바이트(또는 일부 빠진) 캐시로 굳으면 버전 bump 전까지 그
* 확장 스타일이 사라진 채 고정된다. 종전처럼 매 요청 재시도해 원인이 사라지면 회복한다.
*
* @effects prod_build_with_skipped_extension_is_not_cached
*/
public function test_prod_does_not_cache_when_an_extension_was_skipped_during_merge(): void
{
$this->app['env'] = 'production';
app()->detectEnvironment(fn () => 'production');
$cssPath = $this->writeFixture('unreadable.css', '.a{color:red}');
$this->moduleManager->shouldReceive('getActiveModules')->andReturn([
'ext-unreadable' => $this->fakeExtension('ext-unreadable', 100, null, $cssPath),
]);
$svc = new class($this->moduleManager, $this->pluginManager) extends ExtensionBundleService
{
public string $failOn = '';
protected function readAssetSource(string $path): string|false
{
return $path === $this->failOn ? false : parent::readAssetSource($path);
}
};
$svc->failOn = $cssPath;
$this->assertSame('', $svc->getBundleFilePath('module', 'css', 424250));
$this->assertFileDoesNotExist(storage_path('app/ext-bundles/module.424250.css'));
// 원인이 사라지면 다음 요청이 정상 캐시한다 (매 요청 재시도)
$svc->failOn = '';
$path = $svc->getBundleFilePath('module', 'css', 424250);
$this->assertNotSame('', $path);
$this->assertStringEqualsFile($path, '.a{color:red}');
}
}