https://github.com/gnuboard/g7/issues/122 제보자의 추가 지적 2건(빌드가 게시본을 지움 / CLI 최초 게시 후 웹 재생성 불가)에 대응하고, 같은 근본 원인을 공유하는 결함을 전수조사로 함께 고친다. - 빌드가 자기 산출물만 교체한다: 루트 vite `emptyOutDir: false` 기본값 true 는 폴백 없는 코어 3번들과 배달된 immutable URL 의 게시본을 함께 지웠다 - 게시 트리 권한을 웹이 이어받는다: 병합 전 프리플라이트 + 부모 그룹 상속·g+w 종전 소유권 정상화는 root 축만 덮어 비-root CLI 계정은 무방비였다 - 갱신 → 생성 → 완전성 확인을 한 묶음으로: 기록 바이트 대조, .old 원자 스왑, rename 일시 거부 재시도, 프론트 JSON 파싱 검증 - 실패를 억제하고 기록한다: 버전 한정 실패 마커 + 사유별 대시보드 알림 + ext-static:status 점검 커맨드 - 파생: catch-all 제외 목록을 에셋 화이트리스트 합집합에서 파생(mjs·webp·otf 누락), 번들 디스크 쓰기 fail-soft·빈 번들 503, 활성 디렉토리 prune 회피
195 lines
7.9 KiB
PHP
195 lines
7.9 KiB
PHP
<?php
|
|
|
|
namespace Tests\Feature\Api\Public;
|
|
|
|
use App\Services\ExtensionBundleService;
|
|
use Illuminate\Support\Facades\File;
|
|
use Mockery;
|
|
use Tests\TestCase;
|
|
|
|
/**
|
|
* 확장 프론트엔드 병합 번들 서빙 엔드포인트 Feature 테스트
|
|
*
|
|
* /api/{modules,plugins}/bundle.{js,css} 의 응답 계약(Content-Type, ETag,
|
|
* 304 Not Modified, 빈 번들 처리)을 검증한다. ExtensionBundleService 를
|
|
* 컨테이너에 mock 바인딩해 활성 확장 조합에 의존하지 않고 컨트롤러 결선을 검증한다.
|
|
*/
|
|
class ExtensionBundleServingTest extends TestCase
|
|
{
|
|
private string $fixtureDir;
|
|
|
|
protected function setUp(): void
|
|
{
|
|
parent::setUp();
|
|
$this->fixtureDir = storage_path('framework/testing/ext-bundle-serving');
|
|
File::ensureDirectoryExists($this->fixtureDir);
|
|
}
|
|
|
|
protected function tearDown(): void
|
|
{
|
|
if (is_dir($this->fixtureDir)) {
|
|
foreach (glob($this->fixtureDir.'/*') as $f) {
|
|
@unlink($f);
|
|
}
|
|
@rmdir($this->fixtureDir);
|
|
}
|
|
Mockery::close();
|
|
parent::tearDown();
|
|
}
|
|
|
|
/**
|
|
* 번들 서비스를 모킹합니다.
|
|
*
|
|
* `$memoryContent` / `$declaredCount` 는 빈 경로(`$returnPath === ''`)일 때의 분기를
|
|
* 정한다 — 디스크 캐시 실패(메모리 폴백)인지, 선언 0개(정상 빈 번들)인지,
|
|
* 선언은 있는데 결과가 0인지(장애)를 컨트롤러가 이 둘로 가른다.
|
|
*
|
|
* @param string $returnPath getBundleFilePath 반환값
|
|
* @param string $memoryContent buildBundleContent 반환값
|
|
* @param int $declaredCount countAssetDeclaringExtensions 반환값
|
|
*/
|
|
private function bindBundleService(string $returnPath, string $memoryContent = '', int $declaredCount = 0): void
|
|
{
|
|
$mock = Mockery::mock(ExtensionBundleService::class);
|
|
$mock->shouldReceive('getCurrentVersion')->andReturn(777);
|
|
$mock->shouldReceive('getBundleFilePath')->andReturn($returnPath);
|
|
$mock->shouldReceive('buildBundleContent')->andReturn($memoryContent);
|
|
$mock->shouldReceive('countAssetDeclaringExtensions')->andReturn($declaredCount);
|
|
$this->app->instance(ExtensionBundleService::class, $mock);
|
|
}
|
|
|
|
public function test_module_bundle_js_serves_merged_file_with_etag(): void
|
|
{
|
|
$path = $this->fixtureDir.'/module.777.js';
|
|
File::put($path, "(function(){window.A=1})()\n;\n(function(){window.B=2})()");
|
|
$this->bindBundleService($path);
|
|
|
|
$response = $this->get('/api/modules/bundle.js?v=777');
|
|
|
|
$response->assertOk();
|
|
$this->assertStringStartsWith('text/javascript', $response->headers->get('Content-Type'));
|
|
$response->assertHeader('ETag');
|
|
$this->assertStringContainsString('window.A=1', $response->streamedContent() ?? $response->getContent());
|
|
$this->assertStringContainsString("\n;\n", $response->streamedContent() ?? $response->getContent());
|
|
}
|
|
|
|
public function test_bundle_returns_304_on_matching_etag(): void
|
|
{
|
|
$path = $this->fixtureDir.'/plugin.777.js';
|
|
File::put($path, '(function(){})()');
|
|
$this->bindBundleService($path);
|
|
|
|
$first = $this->get('/api/plugins/bundle.js?v=777');
|
|
$first->assertOk();
|
|
$etag = $first->headers->get('ETag');
|
|
$this->assertNotNull($etag);
|
|
|
|
$second = $this->get('/api/plugins/bundle.js?v=777', ['If-None-Match' => $etag]);
|
|
$second->assertStatus(304);
|
|
}
|
|
|
|
public function test_empty_bundle_returns_empty_ok_response(): void
|
|
{
|
|
// 에셋을 선언한 활성 확장이 0개 → 빈 번들이 정상이다 → 빈 200
|
|
$this->bindBundleService('', memoryContent: '', declaredCount: 0);
|
|
|
|
$response = $this->get('/api/modules/bundle.js?v=777');
|
|
|
|
$response->assertOk();
|
|
$this->assertStringStartsWith('text/javascript', $response->headers->get('Content-Type'));
|
|
// 빈 번들은 일반 Response(스트림 아님) — getContent 로 빈 본문 확인
|
|
$this->assertSame('', $response->getContent());
|
|
}
|
|
|
|
/**
|
|
* 디스크 캐시 실패는 500 이 아니라 메모리 병합 결과 200 이다 (E1 fail-soft).
|
|
*
|
|
* `ext-bundles` 디스크는 `throw => true` 라 권한 문제(uid 독점 0700 등)에서
|
|
* `UnableToWriteFile` 이 그대로 올라온다. 캐시는 최적화일 뿐이므로 그 실패가
|
|
* 모든 확장의 프론트엔드 JS/CSS 를 통째로 막으면 안 된다.
|
|
*
|
|
* @effects bundle_disk_write_failure_falls_back_to_memory
|
|
*/
|
|
public function test_disk_cache_failure_serves_memory_merged_result(): void
|
|
{
|
|
// 디스크 캐시 실패 → 경로는 빈 문자열, 그러나 병합 결과는 메모리에 있다
|
|
$this->bindBundleService('', memoryContent: '(function(){window.MEM=1})()', declaredCount: 2);
|
|
|
|
$response = $this->get('/api/modules/bundle.js?v=777');
|
|
|
|
$response->assertOk();
|
|
$this->assertStringStartsWith('text/javascript', $response->headers->get('Content-Type'));
|
|
$this->assertStringContainsString('window.MEM=1', $response->getContent());
|
|
// 캐시에 실패한 산출물이므로 immutable 로 박제하지 않는다
|
|
$this->assertStringContainsString('no-cache', (string) $response->headers->get('Cache-Control'));
|
|
}
|
|
|
|
/**
|
|
* 선언은 있는데 병합 결과가 비면 503 이다 (E2).
|
|
*
|
|
* 배포 중 `dist` 가 잠깐 비면 **모든 확장이 빠진 빈 번들**이 200 으로 나가고, 프론트는
|
|
* 404 도 오류도 받지 못한 채 한참 뒤 "Unknown action handler" 로 죽는다 — 그 시점에는
|
|
* 원인이 번들이라는 사실이 화면에도 로그에도 남아 있지 않다.
|
|
*
|
|
* @effects bundle_empty_with_declared_assets_returns_503
|
|
*/
|
|
public function test_empty_bundle_with_declared_assets_returns_503(): void
|
|
{
|
|
$this->bindBundleService('', memoryContent: '', declaredCount: 3);
|
|
|
|
$response = $this->get('/api/modules/bundle.js?v=777');
|
|
|
|
$response->assertStatus(503);
|
|
}
|
|
|
|
/**
|
|
* 플러그인 경로도 같은 판정을 쓴다 — 두 컨트롤러가 갈라지지 않는다 (장애 쪽).
|
|
*
|
|
* @effects bundle_decision_is_shared_across_extension_types
|
|
*/
|
|
public function test_plugin_bundle_returns_503_when_declared_but_empty(): void
|
|
{
|
|
$this->bindBundleService('', memoryContent: '', declaredCount: 1);
|
|
|
|
$this->get('/api/plugins/bundle.css?v=777')->assertStatus(503);
|
|
}
|
|
|
|
/**
|
|
* 플러그인 경로도 같은 판정을 쓴다 — 두 컨트롤러가 갈라지지 않는다 (정상 쪽).
|
|
*
|
|
* @effects bundle_decision_is_shared_across_extension_types
|
|
*/
|
|
public function test_plugin_bundle_returns_empty_ok_when_nothing_declared(): void
|
|
{
|
|
$this->bindBundleService('', memoryContent: '', declaredCount: 0);
|
|
|
|
$this->get('/api/plugins/bundle.css?v=777')->assertOk();
|
|
}
|
|
|
|
public function test_module_bundle_css_serves_with_css_content_type(): void
|
|
{
|
|
$path = $this->fixtureDir.'/module.777.css';
|
|
File::put($path, '.a{color:red}');
|
|
$this->bindBundleService($path);
|
|
|
|
$response = $this->get('/api/modules/bundle.css?v=777');
|
|
|
|
$response->assertOk();
|
|
$this->assertStringStartsWith('text/css', $response->headers->get('Content-Type'));
|
|
}
|
|
|
|
public function test_bundle_routes_do_not_collide_with_asset_route(): void
|
|
{
|
|
// bundle.js 는 assets/{identifier}/{path} 로 매칭되면 안 됨 (정적 세그먼트 우선)
|
|
$path = $this->fixtureDir.'/module.777.js';
|
|
File::put($path, '(function(){window.BUNDLE=1})()');
|
|
$this->bindBundleService($path);
|
|
|
|
$response = $this->get('/api/modules/bundle.js?v=777');
|
|
|
|
$response->assertOk();
|
|
// 개별 에셋 서빙(serveAsset)이 아니라 번들 서빙(serveBundleJs)이 응답
|
|
$this->assertStringContainsString('window.BUNDLE=1', $response->streamedContent() ?? $response->getContent());
|
|
}
|
|
}
|