공개 자산 스토리지를 설정해도 레이아웃 배경 이미지가 매번 오리진 PHP 를 거쳐 서빙되던 결함을 고친다 (https://github.com/gnuboard/g7/issues/134). 판정 기준은 운영자가 명시 선언한 core.storage.public_asset_disk 와 행 disk 의 일치다 — 디스크의 url 설정 유무로 판정하면 비공개 버킷에 공개 URL 이 설정된 구성에서 발급된 주소가 403 이 되어 그 이미지가 전부 깨진다. 전수조사에서 함께 처리한 것: 게시판 첨부 응답 url 칸(항상 null → 게이트가 살아 있는 서빙 URL), 페이지의 미배선 메서드 삭제, 확장 카테고리별 저장소 URL·경로 조회 교정, 그리고 이번 변경이 새로 만드는 고아 disk 위험 방어 (무인증 공개 서빙 라우트가 500 이 되는 것을 404 로 degrade). 문서화 하네스도 함께 보강한다. 실측을 수행하고도 이력 문서에는 판정만 남기면 대화 로그가 사라진 뒤 아무에게도 도달하지 않는데, 그 강제가 보고 형식에만 있었다.
877 lines
30 KiB
PHP
877 lines
30 KiB
PHP
<?php
|
|
|
|
namespace Modules\Sirsoft\Board\Tests\Unit;
|
|
|
|
// ModuleTestCase를 수동으로 require (autoload 전에 로드 필요)
|
|
require_once __DIR__.'/../ModuleTestCase.php';
|
|
|
|
use App\Contracts\Extension\StorageInterface;
|
|
use App\Extension\HookManager;
|
|
use App\Models\User;
|
|
use Illuminate\Database\Eloquent\Collection;
|
|
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
|
|
use Illuminate\Http\UploadedFile;
|
|
use Illuminate\Support\Facades\Auth;
|
|
use Mockery;
|
|
use Mockery\MockInterface;
|
|
use Modules\Sirsoft\Board\Models\Attachment;
|
|
use Modules\Sirsoft\Board\Models\Board;
|
|
use Modules\Sirsoft\Board\Repositories\Contracts\AttachmentRepositoryInterface;
|
|
use Modules\Sirsoft\Board\Repositories\Contracts\BoardRepositoryInterface;
|
|
use Modules\Sirsoft\Board\Services\AttachmentService;
|
|
use Modules\Sirsoft\Board\Tests\ModuleTestCase;
|
|
use PHPUnit\Framework\Attributes\Test;
|
|
use Symfony\Component\HttpFoundation\StreamedResponse;
|
|
|
|
/**
|
|
* AttachmentService 단위 테스트
|
|
*
|
|
* StorageInterface 기반 파일 업로드, 삭제 등을 테스트합니다.
|
|
*/
|
|
class AttachmentServiceTest extends ModuleTestCase
|
|
{
|
|
private AttachmentService $service;
|
|
|
|
/** @var MockInterface&AttachmentRepositoryInterface */
|
|
private $repository;
|
|
|
|
/** @var MockInterface&BoardRepositoryInterface */
|
|
private $boardRepository;
|
|
|
|
/** @var MockInterface&StorageInterface */
|
|
private $storage;
|
|
|
|
private User $user;
|
|
|
|
protected function setUp(): void
|
|
{
|
|
parent::setUp();
|
|
|
|
// Telescope 비활성화
|
|
config(['telescope.enabled' => false]);
|
|
|
|
// Mock Repository 생성
|
|
$this->repository = Mockery::mock(AttachmentRepositoryInterface::class);
|
|
$this->boardRepository = Mockery::mock(BoardRepositoryInterface::class);
|
|
|
|
// Mock Storage 생성 (직접 Mock)
|
|
$this->storage = Mockery::mock(StorageInterface::class);
|
|
|
|
// boardRepository 기본 Mock: findBySlug 호출 시 Board Mock 반환
|
|
$mockBoard = Mockery::mock(Board::class)->makePartial();
|
|
$mockBoard->id = 1;
|
|
$this->boardRepository->shouldReceive('findBySlug')->andReturn($mockBoard);
|
|
|
|
// Service 생성 (Phase 8: boardRepository 추가)
|
|
$this->service = new AttachmentService($this->repository, $this->boardRepository, $this->storage);
|
|
|
|
// 테스트 사용자 생성
|
|
$this->user = User::factory()->create();
|
|
Auth::login($this->user);
|
|
}
|
|
|
|
protected function tearDown(): void
|
|
{
|
|
Mockery::close();
|
|
parent::tearDown();
|
|
}
|
|
|
|
#[Test]
|
|
public function test_upload_stores_file_and_creates_attachment(): void
|
|
{
|
|
// Arrange
|
|
$slug = 'notice';
|
|
$postId = 1;
|
|
$file = UploadedFile::fake()->create('document.pdf', 100);
|
|
|
|
// Storage Mock 기대값
|
|
$this->storage
|
|
->shouldReceive('put')
|
|
->once()
|
|
->withArgs(function ($category, $path, $contents) {
|
|
return $category === 'attachments'
|
|
&& str_contains($path, 'notice/')
|
|
&& str_ends_with($path, '.pdf');
|
|
})
|
|
->andReturn(true);
|
|
|
|
$this->storage
|
|
->shouldReceive('getDisk')
|
|
->andReturn('local');
|
|
|
|
// Repository Mock 기대값
|
|
$this->repository
|
|
->shouldReceive('getMaxOrder')
|
|
->once()
|
|
->with($slug, $postId, 'attachments')
|
|
->andReturn(0);
|
|
|
|
$expectedAttachment = new Attachment([
|
|
'post_id' => $postId,
|
|
'original_filename' => 'document.pdf',
|
|
'disk' => 'local',
|
|
'collection' => 'attachments',
|
|
'order' => 1,
|
|
]);
|
|
$expectedAttachment->id = 1;
|
|
|
|
$this->repository
|
|
->shouldReceive('create')
|
|
->once()
|
|
->with(Mockery::on(function ($slug) {
|
|
return $slug === 'notice';
|
|
}), Mockery::on(function ($data) use ($postId) {
|
|
return $data['post_id'] === $postId
|
|
&& $data['original_filename'] === 'document.pdf'
|
|
&& $data['disk'] === 'local'
|
|
&& $data['collection'] === 'attachments'
|
|
&& $data['order'] === 1
|
|
&& $data['created_by'] === $this->user->id;
|
|
}))
|
|
->andReturn($expectedAttachment);
|
|
|
|
// Act
|
|
$result = $this->service->upload($slug, $file, $postId);
|
|
|
|
// Assert
|
|
$this->assertEquals(1, $result->id);
|
|
$this->assertEquals('document.pdf', $result->original_filename);
|
|
}
|
|
|
|
#[Test]
|
|
public function test_upload_with_temp_key_creates_temp_attachment(): void
|
|
{
|
|
// Arrange
|
|
$slug = 'notice';
|
|
$tempKey = 'temp-uuid-123';
|
|
$file = UploadedFile::fake()->image('photo.jpg');
|
|
|
|
$this->storage
|
|
->shouldReceive('put')
|
|
->once()
|
|
->andReturn(true);
|
|
|
|
$this->storage
|
|
->shouldReceive('getDisk')
|
|
->andReturn('local');
|
|
|
|
$this->repository
|
|
->shouldReceive('getMaxOrderByTempKey')
|
|
->once()
|
|
->with($slug, $tempKey, 'attachments')
|
|
->andReturn(0);
|
|
|
|
$expectedAttachment = new Attachment;
|
|
$expectedAttachment->id = 1;
|
|
$expectedAttachment->post_id = null;
|
|
$expectedAttachment->temp_key = $tempKey;
|
|
$expectedAttachment->original_filename = 'photo.jpg';
|
|
$expectedAttachment->disk = 'local';
|
|
$expectedAttachment->collection = 'attachments';
|
|
$expectedAttachment->order = 1;
|
|
|
|
$this->repository
|
|
->shouldReceive('create')
|
|
->once()
|
|
->with($slug, Mockery::on(function ($data) use ($tempKey) {
|
|
return $data['post_id'] === null
|
|
&& $data['temp_key'] === $tempKey
|
|
&& $data['original_filename'] === 'photo.jpg';
|
|
}))
|
|
->andReturn($expectedAttachment);
|
|
|
|
// Act
|
|
$result = $this->service->upload($slug, $file, null, 'attachments', $tempKey);
|
|
|
|
// Assert
|
|
$this->assertNull($result->post_id);
|
|
$this->assertEquals($tempKey, $result->temp_key);
|
|
}
|
|
|
|
#[Test]
|
|
public function test_upload_creates_file_in_temp_path_when_no_post_id(): void
|
|
{
|
|
// Arrange
|
|
$slug = 'notice';
|
|
$tempKey = 'temp-uuid-456';
|
|
$file = UploadedFile::fake()->create('document.pdf', 100);
|
|
|
|
// 핵심: postId=null일 때 임시 경로 사용 확인
|
|
$this->storage
|
|
->shouldReceive('put')
|
|
->once()
|
|
->withArgs(function ($category, $path, $contents) use ($slug, $tempKey) {
|
|
return $category === 'attachments'
|
|
&& str_starts_with($path, "{$slug}/temp/{$tempKey}/")
|
|
&& str_ends_with($path, '.pdf');
|
|
})
|
|
->andReturn(true);
|
|
|
|
$this->storage->shouldReceive('getDisk')->andReturn('local');
|
|
$this->repository->shouldReceive('getMaxOrderByTempKey')->andReturn(0);
|
|
|
|
$expectedAttachment = new Attachment(['original_filename' => 'document.pdf']);
|
|
$expectedAttachment->id = 1;
|
|
$this->repository->shouldReceive('create')
|
|
->once()
|
|
->with($slug, Mockery::on(function ($data) use ($slug, $tempKey) {
|
|
// path가 임시 경로인지 검증
|
|
return $data['post_id'] === null
|
|
&& $data['temp_key'] === $tempKey
|
|
&& str_starts_with($data['path'], "{$slug}/temp/{$tempKey}/");
|
|
}))
|
|
->andReturn($expectedAttachment);
|
|
|
|
// Act
|
|
$result = $this->service->upload($slug, $file, null, 'attachments', $tempKey);
|
|
|
|
// Assert
|
|
$this->assertEquals(1, $result->id);
|
|
}
|
|
|
|
#[Test]
|
|
public function test_upload_creates_file_in_final_path_when_post_id_exists(): void
|
|
{
|
|
// Arrange
|
|
$slug = 'notice';
|
|
$postId = 10;
|
|
$file = UploadedFile::fake()->create('document.pdf', 100);
|
|
$datePath = date('Y/m/d');
|
|
|
|
// 핵심: postId가 있을 때 최종 경로 사용 확인
|
|
$this->storage
|
|
->shouldReceive('put')
|
|
->once()
|
|
->withArgs(function ($category, $path, $contents) use ($slug, $datePath) {
|
|
return $category === 'attachments'
|
|
&& str_starts_with($path, "{$slug}/{$datePath}/")
|
|
&& ! str_contains($path, '/temp/')
|
|
&& str_ends_with($path, '.pdf');
|
|
})
|
|
->andReturn(true);
|
|
|
|
$this->storage->shouldReceive('getDisk')->andReturn('local');
|
|
$this->repository->shouldReceive('getMaxOrder')->andReturn(0);
|
|
|
|
$expectedAttachment = new Attachment(['original_filename' => 'document.pdf']);
|
|
$expectedAttachment->id = 1;
|
|
$this->repository->shouldReceive('create')
|
|
->once()
|
|
->with($slug, Mockery::on(function ($data) use ($postId, $slug, $datePath) {
|
|
// path가 최종 경로인지 검증
|
|
return $data['post_id'] === $postId
|
|
&& $data['temp_key'] === null
|
|
&& str_starts_with($data['path'], "{$slug}/{$datePath}/")
|
|
&& ! str_contains($data['path'], '/temp/');
|
|
}))
|
|
->andReturn($expectedAttachment);
|
|
|
|
// Act
|
|
$result = $this->service->upload($slug, $file, $postId);
|
|
|
|
// Assert
|
|
$this->assertEquals(1, $result->id);
|
|
}
|
|
|
|
#[Test]
|
|
public function test_link_temp_attachments_with_move_moves_files_and_updates_db(): void
|
|
{
|
|
// Arrange
|
|
$slug = 'notice';
|
|
$tempKey = 'temp-uuid-789';
|
|
$postId = 5;
|
|
$datePath = date('Y/m/d');
|
|
|
|
// Phase 8: $attachment->update() 직접 호출이므로 Mockery mock 사용
|
|
$attachment1 = Mockery::mock(Attachment::class)->makePartial();
|
|
$attachment1->path = "{$slug}/temp/{$tempKey}/uuid1.pdf";
|
|
$attachment1->stored_filename = 'uuid1.pdf';
|
|
$attachment1->id = 1;
|
|
$attachment1->shouldReceive('update')
|
|
->once()
|
|
->with(Mockery::on(function ($data) use ($postId, $slug, $datePath) {
|
|
return $data['post_id'] === $postId
|
|
&& $data['temp_key'] === null
|
|
&& str_starts_with($data['path'], "{$slug}/{$datePath}/")
|
|
&& str_ends_with($data['path'], 'uuid1.pdf')
|
|
&& isset($data['board_id']);
|
|
}))
|
|
->andReturn(true);
|
|
|
|
$attachment2 = Mockery::mock(Attachment::class)->makePartial();
|
|
$attachment2->path = "{$slug}/temp/{$tempKey}/uuid2.jpg";
|
|
$attachment2->stored_filename = 'uuid2.jpg';
|
|
$attachment2->id = 2;
|
|
$attachment2->shouldReceive('update')
|
|
->once()
|
|
->with(Mockery::on(function ($data) use ($postId, $slug, $datePath) {
|
|
return $data['post_id'] === $postId
|
|
&& $data['temp_key'] === null
|
|
&& str_starts_with($data['path'], "{$slug}/{$datePath}/")
|
|
&& str_ends_with($data['path'], 'uuid2.jpg')
|
|
&& isset($data['board_id']);
|
|
}))
|
|
->andReturn(true);
|
|
|
|
$tempAttachments = new EloquentCollection([$attachment1, $attachment2]);
|
|
|
|
// Repository: 임시 첨부파일 조회
|
|
$this->repository
|
|
->shouldReceive('getByTempKey')
|
|
->once()
|
|
->with($slug, $tempKey)
|
|
->andReturn($tempAttachments);
|
|
|
|
// Storage: 파일 이동 (get + put + delete) x 2
|
|
$this->storage
|
|
->shouldReceive('get')
|
|
->twice()
|
|
->withArgs(function ($category, $path) {
|
|
return $category === 'attachments' && str_contains($path, '/temp/');
|
|
})
|
|
->andReturn('file-content');
|
|
|
|
$this->storage
|
|
->shouldReceive('put')
|
|
->twice()
|
|
->withArgs(function ($category, $path) use ($slug, $datePath) {
|
|
return $category === 'attachments'
|
|
&& str_starts_with($path, "{$slug}/{$datePath}/")
|
|
&& ! str_contains($path, '/temp/');
|
|
})
|
|
->andReturn(true);
|
|
|
|
$this->storage
|
|
->shouldReceive('delete')
|
|
->twice()
|
|
->withArgs(function ($category, $path) {
|
|
return $category === 'attachments' && str_contains($path, '/temp/');
|
|
})
|
|
->andReturn(true);
|
|
|
|
// Storage: 임시 디렉토리 정리
|
|
$this->storage
|
|
->shouldReceive('deleteDirectory')
|
|
->once()
|
|
->with('attachments', "{$slug}/temp/{$tempKey}")
|
|
->andReturn(true);
|
|
|
|
// Act
|
|
$result = $this->service->linkTempAttachmentsWithMove($slug, $tempKey, $postId);
|
|
|
|
// Assert
|
|
$this->assertEquals(2, $result);
|
|
}
|
|
|
|
#[Test]
|
|
public function test_link_temp_attachments_with_move_handles_empty_temp_files(): void
|
|
{
|
|
// Arrange
|
|
$slug = 'notice';
|
|
$tempKey = 'temp-uuid-empty';
|
|
$postId = 5;
|
|
|
|
$this->repository
|
|
->shouldReceive('getByTempKey')
|
|
->once()
|
|
->with($slug, $tempKey)
|
|
->andReturn(new EloquentCollection([]));
|
|
|
|
// 파일이 없어도 임시 디렉토리 정리는 실행
|
|
$this->storage
|
|
->shouldReceive('deleteDirectory')
|
|
->once()
|
|
->with('attachments', "{$slug}/temp/{$tempKey}")
|
|
->andReturn(true);
|
|
|
|
// Act
|
|
$result = $this->service->linkTempAttachmentsWithMove($slug, $tempKey, $postId);
|
|
|
|
// Assert
|
|
$this->assertEquals(0, $result);
|
|
}
|
|
|
|
#[Test]
|
|
public function test_upload_fires_hooks(): void
|
|
{
|
|
// Arrange
|
|
$slug = 'notice';
|
|
$beforeUploadFired = false;
|
|
$afterUploadFired = false;
|
|
$filterApplied = false;
|
|
|
|
HookManager::addAction('sirsoft-board.attachment.before_upload', function () use (&$beforeUploadFired) {
|
|
$beforeUploadFired = true;
|
|
});
|
|
|
|
HookManager::addFilter('sirsoft-board.attachment.filter_upload_file', function ($file) use (&$filterApplied) {
|
|
$filterApplied = true;
|
|
|
|
return $file;
|
|
});
|
|
|
|
HookManager::addAction('sirsoft-board.attachment.after_upload', function () use (&$afterUploadFired) {
|
|
$afterUploadFired = true;
|
|
});
|
|
|
|
$file = UploadedFile::fake()->create('test.pdf');
|
|
|
|
$this->storage->shouldReceive('put')->andReturn(true);
|
|
$this->storage->shouldReceive('getDisk')->andReturn('local');
|
|
$this->repository->shouldReceive('getMaxOrder')->andReturn(0);
|
|
|
|
$attachment = new Attachment(['id' => 1]);
|
|
$this->repository->shouldReceive('create')->andReturn($attachment);
|
|
|
|
// Act
|
|
$this->service->upload($slug, $file, 1);
|
|
|
|
// Assert
|
|
$this->assertTrue($beforeUploadFired, 'before_upload hook should be fired');
|
|
$this->assertTrue($filterApplied, 'filter_upload_file hook should be applied');
|
|
$this->assertTrue($afterUploadFired, 'after_upload hook should be fired');
|
|
|
|
// Cleanup hooks
|
|
HookManager::clearAction('sirsoft-board.attachment.before_upload');
|
|
HookManager::clearFilter('sirsoft-board.attachment.filter_upload_file');
|
|
HookManager::clearAction('sirsoft-board.attachment.after_upload');
|
|
}
|
|
|
|
#[Test]
|
|
public function test_delete_soft_deletes_without_removing_physical_file(): void
|
|
{
|
|
// Arrange
|
|
// 물리 파일은 삭제하지 않고 소프트 딜리트만 수행 (배치 정리 예정)
|
|
$slug = 'notice';
|
|
$attachmentId = 1;
|
|
|
|
$attachment = new Attachment([
|
|
'post_id' => 1,
|
|
'path' => 'notice/2024/01/19/file.pdf',
|
|
'collection' => 'attachments',
|
|
]);
|
|
$attachment->id = $attachmentId;
|
|
|
|
$this->repository
|
|
->shouldReceive('findById')
|
|
->once()
|
|
->with($slug, $attachmentId)
|
|
->andReturn($attachment);
|
|
|
|
// 물리 파일 삭제(storage->exists, storage->delete)는 호출되지 않아야 함
|
|
$this->storage->shouldNotReceive('exists');
|
|
$this->storage->shouldNotReceive('delete');
|
|
|
|
$this->repository
|
|
->shouldReceive('delete')
|
|
->once()
|
|
->with($slug, $attachmentId)
|
|
->andReturn(true);
|
|
|
|
$this->repository
|
|
->shouldReceive('getByPost')
|
|
->once()
|
|
->with($slug, 1, 'attachments')
|
|
->andReturn(new EloquentCollection([]));
|
|
|
|
// Act
|
|
$result = $this->service->delete($slug, $attachmentId);
|
|
|
|
// Assert
|
|
$this->assertTrue($result);
|
|
}
|
|
|
|
#[Test]
|
|
public function test_delete_fires_hooks(): void
|
|
{
|
|
// Arrange
|
|
$slug = 'notice';
|
|
$beforeDeleteFired = false;
|
|
$afterDeleteFired = false;
|
|
|
|
HookManager::addAction('sirsoft-board.attachment.before_delete', function () use (&$beforeDeleteFired) {
|
|
$beforeDeleteFired = true;
|
|
});
|
|
|
|
HookManager::addAction('sirsoft-board.attachment.after_delete', function () use (&$afterDeleteFired) {
|
|
$afterDeleteFired = true;
|
|
});
|
|
|
|
$attachment = new Attachment(['post_id' => 1, 'path' => 'test.pdf', 'collection' => 'attachments']);
|
|
$attachment->id = 1;
|
|
|
|
$this->repository->shouldReceive('findById')->andReturn($attachment);
|
|
$this->repository->shouldReceive('delete')->andReturn(true);
|
|
$this->repository->shouldReceive('getByPost')->andReturn(new EloquentCollection([]));
|
|
|
|
// Act
|
|
$this->service->delete($slug, 1);
|
|
|
|
// Assert
|
|
$this->assertTrue($beforeDeleteFired, 'before_delete hook should be fired');
|
|
$this->assertTrue($afterDeleteFired, 'after_delete hook should be fired');
|
|
|
|
// Cleanup hooks
|
|
HookManager::clearAction('sirsoft-board.attachment.before_delete');
|
|
HookManager::clearAction('sirsoft-board.attachment.after_delete');
|
|
}
|
|
|
|
#[Test]
|
|
public function test_link_temp_attachments_links_temp_files_to_post(): void
|
|
{
|
|
// Arrange
|
|
$slug = 'notice';
|
|
$tempKey = 'temp-uuid-123';
|
|
$postId = 5;
|
|
|
|
// Service 는 훅 발화를 위해 getByTempKey → linkTempAttachments → findById 순으로 호출한다.
|
|
// Repository 시그니처: Eloquent\Collection 반환 필수 (Support\Collection 불일치)
|
|
//
|
|
// 후보 컬렉션이 비어 있으면 재조회 루프가 0회 실행되어 재조회 메서드 이름이 틀려도
|
|
// 통과한다(실제로 없는 `getById()` 를 호출하고 있었는데 그 상태로 green 이었다).
|
|
// 따라서 첨부 1건이 든 컬렉션으로 루프를 반드시 태운다.
|
|
$tempAttachment = new Attachment(['temp_key' => $tempKey]);
|
|
$tempAttachment->id = 11;
|
|
|
|
$linkedAttachment = new Attachment;
|
|
$linkedAttachment->id = 11;
|
|
$linkedAttachment->post_id = $postId;
|
|
|
|
$this->repository
|
|
->shouldReceive('getByTempKey')
|
|
->once()
|
|
->with($slug, $tempKey)
|
|
->andReturn(new Collection([$tempAttachment]));
|
|
|
|
$this->repository
|
|
->shouldReceive('linkTempAttachments')
|
|
->once()
|
|
->with($slug, $tempKey, $postId)
|
|
->andReturn(3);
|
|
|
|
$this->repository
|
|
->shouldReceive('findById')
|
|
->once()
|
|
->with($slug, 11)
|
|
->andReturn($linkedAttachment);
|
|
|
|
// Act
|
|
$result = $this->service->linkTempAttachments($slug, $tempKey, $postId);
|
|
|
|
// Assert
|
|
$this->assertEquals(3, $result);
|
|
}
|
|
|
|
#[Test]
|
|
public function test_reorder_updates_attachment_order(): void
|
|
{
|
|
// Arrange
|
|
$slug = 'notice';
|
|
$orders = [
|
|
1 => 3,
|
|
2 => 1,
|
|
3 => 2,
|
|
];
|
|
|
|
$this->repository
|
|
->shouldReceive('reorder')
|
|
->once()
|
|
->with($slug, $orders)
|
|
->andReturn(true);
|
|
|
|
// Act
|
|
$result = $this->service->reorder($slug, $orders);
|
|
|
|
// Assert
|
|
$this->assertTrue($result);
|
|
}
|
|
|
|
#[Test]
|
|
public function test_reorder_fires_hooks(): void
|
|
{
|
|
// Arrange
|
|
$slug = 'notice';
|
|
$beforeReorderFired = false;
|
|
$afterReorderFired = false;
|
|
|
|
HookManager::addAction('sirsoft-board.attachment.before_reorder', function () use (&$beforeReorderFired) {
|
|
$beforeReorderFired = true;
|
|
});
|
|
|
|
HookManager::addAction('sirsoft-board.attachment.after_reorder', function () use (&$afterReorderFired) {
|
|
$afterReorderFired = true;
|
|
});
|
|
|
|
$this->repository->shouldReceive('reorder')->andReturn(true);
|
|
|
|
// Act
|
|
$this->service->reorder($slug, [1 => 1, 2 => 2]);
|
|
|
|
// Assert
|
|
$this->assertTrue($beforeReorderFired, 'before_reorder hook should be fired');
|
|
$this->assertTrue($afterReorderFired, 'after_reorder hook should be fired');
|
|
|
|
// Cleanup hooks
|
|
HookManager::clearAction('sirsoft-board.attachment.before_reorder');
|
|
HookManager::clearAction('sirsoft-board.attachment.after_reorder');
|
|
}
|
|
|
|
#[Test]
|
|
public function test_download_returns_streamed_response(): void
|
|
{
|
|
// Arrange
|
|
$attachment = new Attachment([
|
|
'id' => 1,
|
|
'path' => 'notice/2025/01/21/test.pdf',
|
|
'original_filename' => 'document.pdf',
|
|
'mime_type' => 'application/pdf',
|
|
]);
|
|
|
|
$this->repository->shouldReceive('findById')
|
|
->once()
|
|
->with('notice', 1)
|
|
->andReturn($attachment);
|
|
|
|
$expectedResponse = new StreamedResponse;
|
|
|
|
$this->storage->shouldReceive('response')
|
|
->once()
|
|
->withArgs(function ($category, $path, $filename, $headers) {
|
|
return $category === 'attachments'
|
|
&& $path === 'notice/2025/01/21/test.pdf'
|
|
&& $filename === 'document.pdf'
|
|
&& $headers['Content-Type'] === 'application/pdf'
|
|
&& str_contains($headers['Content-Disposition'], 'attachment')
|
|
&& str_contains($headers['Content-Disposition'], 'filename="document.pdf"')
|
|
&& str_contains($headers['Content-Disposition'], "filename*=UTF-8''document.pdf");
|
|
})
|
|
->andReturn($expectedResponse);
|
|
|
|
// Act
|
|
$result = $this->service->download('notice', 1);
|
|
|
|
// Assert
|
|
$this->assertInstanceOf(StreamedResponse::class, $result);
|
|
}
|
|
|
|
#[Test]
|
|
public function test_download_returns_null_when_not_found(): void
|
|
{
|
|
// Arrange
|
|
$this->repository->shouldReceive('findById')
|
|
->once()
|
|
->with('notice', 999)
|
|
->andReturn(null);
|
|
|
|
// Act
|
|
$result = $this->service->download('notice', 999);
|
|
|
|
// Assert
|
|
$this->assertNull($result);
|
|
}
|
|
|
|
#[Test]
|
|
public function test_download_with_korean_filename(): void
|
|
{
|
|
// Arrange
|
|
$attachment = new Attachment([
|
|
'id' => 1,
|
|
'path' => 'notice/2025/01/21/test.pdf',
|
|
'original_filename' => '문서파일.pdf',
|
|
'mime_type' => 'application/pdf',
|
|
]);
|
|
|
|
$this->repository->shouldReceive('findById')
|
|
->once()
|
|
->with('notice', 1)
|
|
->andReturn($attachment);
|
|
|
|
$expectedResponse = new StreamedResponse;
|
|
|
|
$this->storage->shouldReceive('response')
|
|
->once()
|
|
->withArgs(function ($category, $path, $filename, $headers) {
|
|
$expectedEncoded = rawurlencode('문서파일.pdf');
|
|
|
|
return $category === 'attachments'
|
|
&& $path === 'notice/2025/01/21/test.pdf'
|
|
&& $filename === '문서파일.pdf'
|
|
&& $headers['Content-Type'] === 'application/pdf'
|
|
&& str_contains($headers['Content-Disposition'], 'attachment')
|
|
&& str_contains($headers['Content-Disposition'], 'filename="문서파일.pdf"')
|
|
&& str_contains($headers['Content-Disposition'], "filename*=UTF-8''{$expectedEncoded}");
|
|
})
|
|
->andReturn($expectedResponse);
|
|
|
|
// Act
|
|
$result = $this->service->download('notice', 1);
|
|
|
|
// Assert
|
|
$this->assertInstanceOf(StreamedResponse::class, $result);
|
|
}
|
|
|
|
#[Test]
|
|
public function test_download_fires_after_download_hook(): void
|
|
{
|
|
// Arrange (#413-58: 다운로드 시 활동이력 기록을 위한 after_download 훅 발화)
|
|
$afterDownloadFired = false;
|
|
$firedAttachment = null;
|
|
$firedContext = null;
|
|
|
|
HookManager::addAction(
|
|
'sirsoft-board.attachment.after_download',
|
|
function ($attachment, $context = null) use (&$afterDownloadFired, &$firedAttachment, &$firedContext) {
|
|
$afterDownloadFired = true;
|
|
$firedAttachment = $attachment;
|
|
$firedContext = $context;
|
|
}
|
|
);
|
|
|
|
$attachment = new Attachment([
|
|
'id' => 1,
|
|
'path' => 'notice/2025/01/21/test.pdf',
|
|
'original_filename' => 'document.pdf',
|
|
'mime_type' => 'application/pdf',
|
|
]);
|
|
|
|
$this->repository->shouldReceive('findById')
|
|
->once()
|
|
->with('notice', 1)
|
|
->andReturn($attachment);
|
|
|
|
$this->storage->shouldReceive('response')
|
|
->once()
|
|
->andReturn(new StreamedResponse);
|
|
|
|
// Act (user 컨텍스트)
|
|
$this->service->download('notice', 1, context: 'user');
|
|
|
|
// Assert
|
|
$this->assertTrue($afterDownloadFired, 'after_download hook should be fired');
|
|
$this->assertSame($attachment, $firedAttachment, 'fired attachment should be the downloaded one');
|
|
$this->assertSame('user', $firedContext, 'context should be passed to the hook');
|
|
|
|
// Cleanup hooks
|
|
HookManager::clearAction('sirsoft-board.attachment.after_download');
|
|
}
|
|
|
|
#[Test]
|
|
public function test_download_passes_admin_context_to_hook(): void
|
|
{
|
|
// Arrange (#413-58: admin 진입점은 context 기본값 'admin' 으로 호출)
|
|
$firedContext = null;
|
|
|
|
HookManager::addAction(
|
|
'sirsoft-board.attachment.after_download',
|
|
function ($attachment, $context = null) use (&$firedContext) {
|
|
$firedContext = $context;
|
|
}
|
|
);
|
|
|
|
$attachment = new Attachment([
|
|
'id' => 1,
|
|
'path' => 'notice/2025/01/21/test.pdf',
|
|
'original_filename' => 'document.pdf',
|
|
'mime_type' => 'application/pdf',
|
|
]);
|
|
|
|
$this->repository->shouldReceive('findById')
|
|
->once()
|
|
->with('notice', 1)
|
|
->andReturn($attachment);
|
|
|
|
$this->storage->shouldReceive('response')
|
|
->once()
|
|
->andReturn(new StreamedResponse);
|
|
|
|
// Act (admin 컨텍스트 기본값)
|
|
$this->service->download('notice', 1);
|
|
|
|
// Assert
|
|
$this->assertSame('admin', $firedContext, 'admin download should pass admin context');
|
|
|
|
// Cleanup hooks
|
|
HookManager::clearAction('sirsoft-board.attachment.after_download');
|
|
}
|
|
|
|
#[Test]
|
|
public function test_download_does_not_fire_hook_when_not_found(): void
|
|
{
|
|
// Arrange (#413-58: 첨부 미존재 시 훅 미발화 — 다운로드 실패는 이력 미기록)
|
|
$afterDownloadFired = false;
|
|
|
|
HookManager::addAction(
|
|
'sirsoft-board.attachment.after_download',
|
|
function () use (&$afterDownloadFired) {
|
|
$afterDownloadFired = true;
|
|
}
|
|
);
|
|
|
|
$this->repository->shouldReceive('findById')
|
|
->once()
|
|
->with('notice', 999)
|
|
->andReturn(null);
|
|
|
|
// Act
|
|
$result = $this->service->download('notice', 999, context: 'user');
|
|
|
|
// Assert
|
|
$this->assertNull($result);
|
|
$this->assertFalse($afterDownloadFired, 'hook should not fire when attachment not found');
|
|
|
|
// Cleanup hooks
|
|
HookManager::clearAction('sirsoft-board.attachment.after_download');
|
|
}
|
|
|
|
/**
|
|
* 업로드 응답의 url 칸이 게이트가 살아 있는 서빙 URL 이어야 합니다.
|
|
*
|
|
* 이전에는 비공개 디스크에서 항상 null 이 나가 응답의 url 칸이 늘 비어 있었다.
|
|
* 게시판 첨부는 비밀글·삭제글 게이트가 걸려 있으므로 직접 URL 로 바꾸지 않고,
|
|
* 그 게이트를 통과하는 다운로드 서빙 URL 로 채운다.
|
|
*
|
|
* @effects board_upload_response_url_uses_gated_route, download_url_falls_back_to_api_path_when_direct_unavailable
|
|
*/
|
|
#[Test]
|
|
public function test_get_url_returns_gated_serving_url(): void
|
|
{
|
|
// Arrange
|
|
$attachment = new Attachment([
|
|
'path' => 'notice/2025/01/21/test.jpg',
|
|
]);
|
|
$attachment->hash = 'abc123def456';
|
|
$attachment->setRelation('board', new Board(['slug' => 'notice']));
|
|
|
|
$this->repository->shouldReceive('findById')
|
|
->once()
|
|
->with('notice', 1)
|
|
->andReturn($attachment);
|
|
|
|
// 직접 URL 은 시도조차 하지 않는다 (게이트 우회 차단)
|
|
$this->storage->shouldNotReceive('url');
|
|
|
|
// Act
|
|
$result = $this->service->getUrl('notice', 1);
|
|
|
|
// Assert
|
|
$this->assertSame(
|
|
'/api/modules/sirsoft-board/boards/notice/attachment/abc123def456',
|
|
$result
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 첨부가 없으면 null 이어야 합니다 (기존 계약 유지).
|
|
*/
|
|
#[Test]
|
|
public function test_get_url_returns_null_when_attachment_missing(): void
|
|
{
|
|
$this->repository->shouldReceive('findById')
|
|
->once()
|
|
->with('notice', 99)
|
|
->andReturnNull();
|
|
|
|
$this->assertNull($this->service->getUrl('notice', 99));
|
|
}
|
|
}
|