Files
Gnuboard7/tests/Unit/Services/ExtensionBundleServiceTest.php
T
HeuJung f32729e20e fix(core): SEO 봇 캐시·확장 번들 캐시의 정합 결함 5건 수정
KVE-2026-2191 대응에서 계획 안 세부를 자율 결정한 지점을 회귀 전수조사해 정합 결함
5건을 고쳤다. 넷은 같은 미출시 버전이 만든 자기교정이고, 마지막 하나는 7.0.10 및 그
이전부터 있던 결함이라 공개 CHANGELOG 에 항목을 넣었다.

- 적중 통계의 레이아웃명이 항상 비어 화면별 표가 성립하지 않던 것 → 페이지 캐시 값에
 레이아웃명을 함께 담고 적중 경로가 그것으로 귀속한다. 이전 버전이 문자열로만 저장한
 항목은 그대로 읽어 배포 직후 살아 있는 캐시를 버리지 않는다. 인터페이스는 불변이고
 코어 구현일 때만 항목 전체를 읽는다.
- 통계 url 컬럼(255)이 캐시 키 URL(정규화 쿼리 최대 512바이트)보다 짧아 긴 주소의 기록이
 엄격 모드에서 실패하고 그 예외를 서비스가 삼키던 것 → 768 로 확장(utf8mb4 인덱스 키
 상한 3072바이트). down 은 잘리는 행을 먼저 지운다.
- 경로당 변종 상한이 언어를 합산해 다국어 사이트의 실효 상한이 언어 수만큼 줄던 것 →
 인덱스 항목이 url|locale 별이므로 경로·언어 조합 단위로 판정한다.
- 그릴 게 없는 요청(미라우트 404·SEO 비활성)이 렌더 예산을 소모해, 캐시에 남지 않는 죽은
 주소 재크롤이 정상 페이지의 예산을 태우던 것 → null 렌더는 차감을 환급한다. 렌더 도중
 예외는 비용을 이미 치른 것이라 환급하지 않는다.
- 병합 단계에서 건너뛴 확장이 있는 결과가 캐시로 굳어 버전 bump 전까지 그 확장 자산이
 사라진 채 고정되던 것 → 병합 루프를 mergeJs/mergeCss 로 분리해 건너뛴 확장을 함께
 돌려주고, 하나라도 있으면 캐시하지 않고 Log::error 로 남긴다. 캐시 없이 돌면 호출측이
 매 요청 다시 병합하므로 원인이 사라지는 즉시 회복한다. 기본 로그 수준이 error 라
 종전 per-extension warning 은 기록되지 않아 흔적이 없었다.

회귀 테스트 8건은 red 확인 후 green 으로 전환했고, 변경 심볼의 소비자 모집단(테스트
클래스 9종)을 프로세스 분할로 재실행해 189건 통과를 확인했다.
2026-09-08 08:17:51 +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;
/**
* 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}');
}
}