인터뷰에서 방향만 정하고 남겨 뒀던 후속 5건을 구현하고, 커밋 전 종합 점검에서 발견한 결함 2건을 함께 고쳤다. 기능 도입: - 업로드 이미지 축소 — `upload.image_max_width` / `image_max_height` / `image_quality` 는 저장·검증만 되고 리사이즈 코드가 0건이었다. 비율 유지, 상한 이내면 재인코딩하지 않음(불필요한 화질 손실 방지), 한계 미설정이면 무동작(기존 사이트 이미지 보호). PNG·GIF·WebP 투명도 보존. 실패해도 업로드는 계속되고 원본이 저장된다. - 로그인 2단계 인증 — `security.two_factor_auth` 는 설정 항목만 있고 구현도 화면 컨트롤도 없었다. 코어 IDV 인프라를 재사용해 purpose `login` 을 추가하고, 비밀번호 확인 통과 후 challenge 만 발행한다(토큰 미발급 + logout). `POST /api/auth/login/two-factor` 가 코드를 확인해야 토큰이 나가며, 로그인과 같은 요청 제한을 적용한다. challenge 의 purpose 를 대조해 다른 흐름의 challenge 로 로그인하는 것을 막는다. 코드 발송이 실패하면 실패로 알린다 — 2단계를 건너뛰고 로그인시키면 통제가 조용히 열린다. 결함 수정: - 게시글 수정 시 사용자 경로가 `attachment_ids` 를 Service 로 넘기지 않아, 검증과 권한은 통과하고 200 을 받는데 첨부만 조용히 연결되지 않았다. 관리자 경로는 넘기고 있어 같은 요청이 화면에 따라 다르게 동작했다. - 설정 화면 라디오가 `pointer-events-none` + Label click 으로만 상태를 바꿔, 키보드 방향키(change) 조작 시 화면 표시와 저장될 값이 어긋났다. 동일 패턴 4개 레이아웃 12개 라디오에 change 액션을 부여하고 params 를 click 과 동일하게 고정한다. - 주문완료 화면이 결제수단 확인보다 영수증 조회를 앞세워, PG 를 거치지 않는 주문에서도 매번 404 요청이 나갔다. - 개별 게시판 폼의 숫자 항목에 경계값 바인딩이 없어 환경설정 화면과 안내가 달랐다. 커밋 전 점검에서 발견: - 2단계 인증이 반쪽으로 들어가 있었다. enum 에 case 만 추가하고 코어 purpose 레지스트리와 ko/en 라벨을 빠뜨려, 목적 목록 API 에서 통째로 빠지고 `hasPurpose` 가 false 였으며 라벨 대신 i18n 키 원문이 반환됐다. 관리자가 이 목적의 메시지 템플릿·정책을 만들 수 없는 상태였다. 세 곳을 enum 기준으로 전수 대조하는 회귀 테스트를 함께 둔다. - 업로드 이미지 축소가 적용되지 않은 8번째 서비스(레이아웃 편집기 첨부)가 있었다. 열거형 커버리지 테스트는 내 조사 결과를 green 으로 고정할 뿐 전수를 증명하지 못한다 — 같은 규약을 조건으로 표현한 audit 룰이 첫 실행에서 검출했다. 재발 방지: - audit 룰 `upload-service-image-resize` (신규 업로드 서비스의 축소 누락 검출) - 트러블슈팅 사례 20 (코어 설정이 일부 경로에만 적용되는 증상의 진단 절차) - `sirsoft-ckeditor5` 의 코어 최소 요구 버전을 7.0.6 으로 상향 (신설 클래스 의존) 공개 이슈: gnuboard/g7
523 lines
20 KiB
PHP
523 lines
20 KiB
PHP
<?php
|
|
|
|
namespace Modules\Sirsoft\Board\Tests\Feature;
|
|
|
|
require_once __DIR__.'/../ModuleTestCase.php';
|
|
|
|
use App\Models\Role;
|
|
use App\Models\User;
|
|
use Illuminate\Http\UploadedFile;
|
|
use Mockery;
|
|
use Modules\Sirsoft\Board\Exceptions\AttachmentLimitExceededException;
|
|
use Modules\Sirsoft\Board\Models\Attachment;
|
|
use Modules\Sirsoft\Board\Models\Post;
|
|
use Modules\Sirsoft\Board\Services\AttachmentService;
|
|
use Modules\Sirsoft\Board\Services\PostService;
|
|
use Modules\Sirsoft\Board\Tests\BoardTestCase;
|
|
|
|
/**
|
|
* 첨부파일 개수 상한 서버측 불변조건 테스트 (B3)
|
|
*
|
|
* 수정 전에는 `files[]` 직접 업로드만 FormRequest 에서 막고, `attachment_ids` · `temp_key` ·
|
|
* 단일 업로드 반복 경로에는 개수 검사가 없었습니다. 같은 8개 파일을 `temp_key` 로 보내면
|
|
* 201 로 전부 연결되어 사용자 페이지에 그대로 노출됐습니다.
|
|
*
|
|
* @group board
|
|
* @group attachment
|
|
*/
|
|
class AttachmentLimitTest extends BoardTestCase
|
|
{
|
|
private User $memberUser;
|
|
|
|
protected function getTestBoardSlug(): string
|
|
{
|
|
return 'attachment-limit';
|
|
}
|
|
|
|
protected function getDefaultBoardAttributes(string $slug): array
|
|
{
|
|
return [
|
|
'slug' => $slug,
|
|
'name' => ['ko' => '첨부 상한 게시판', 'en' => 'Attachment Limit Board'],
|
|
'is_active' => true,
|
|
'use_file_upload' => true,
|
|
'max_file_count' => 5,
|
|
];
|
|
}
|
|
|
|
protected function setUp(): void
|
|
{
|
|
parent::setUp();
|
|
|
|
$this->setGuestPermissions(['posts.read', 'posts.write']);
|
|
$this->grantUserRolePermissions(['posts.read', 'posts.write', 'attachments.upload']);
|
|
|
|
$this->memberUser = User::factory()->create(['email' => 'attachment-limit@test.com']);
|
|
|
|
$userRole = Role::where('identifier', 'user')->first();
|
|
if ($userRole) {
|
|
$this->memberUser->roles()->attach($userRole->id);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 임시 첨부(temp_key) 를 N 개 만듭니다.
|
|
*
|
|
* @param string $tempKey 임시 업로드 키
|
|
* @param int $count 생성 개수
|
|
*/
|
|
private function seedTempAttachments(string $tempKey, int $count): void
|
|
{
|
|
for ($i = 0; $i < $count; $i++) {
|
|
Attachment::create([
|
|
'board_id' => 0,
|
|
'post_id' => null,
|
|
'temp_key' => $tempKey,
|
|
'collection' => 'attachments',
|
|
'original_filename' => "temp-{$i}.png",
|
|
'stored_filename' => "temp-{$i}.png",
|
|
'path' => "{$this->getTestBoardSlug()}/temp/{$tempKey}/temp-{$i}.png",
|
|
'mime_type' => 'image/png',
|
|
'size' => 100,
|
|
'order' => $i,
|
|
]);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 게시글에 연결된 첨부를 N 개 만듭니다.
|
|
*
|
|
* @param int $postId 게시글 ID
|
|
* @param int $count 생성 개수
|
|
*/
|
|
private function seedLinkedAttachments(int $postId, int $count): void
|
|
{
|
|
$board = $this->board;
|
|
|
|
for ($i = 0; $i < $count; $i++) {
|
|
Attachment::create([
|
|
'board_id' => $board->id,
|
|
'post_id' => $postId,
|
|
'collection' => 'attachments',
|
|
'original_filename' => "linked-{$i}.png",
|
|
'stored_filename' => "linked-{$i}.png",
|
|
'path' => "{$this->getTestBoardSlug()}/2026/07/27/linked-{$i}.png",
|
|
'mime_type' => 'image/png',
|
|
'size' => 100,
|
|
'order' => $i,
|
|
]);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* temp_key 경로로 상한을 초과하면 차단된다 (실측 재현된 우회 경로).
|
|
*/
|
|
public function test_temp_key_link_over_limit_is_blocked(): void
|
|
{
|
|
$postId = $this->createTestPost();
|
|
$tempKey = 'tk_'.str_repeat('a', 20);
|
|
$this->seedTempAttachments($tempKey, 8);
|
|
|
|
$this->expectException(AttachmentLimitExceededException::class);
|
|
|
|
app(AttachmentService::class)->linkTempAttachmentsWithMove(
|
|
$this->getTestBoardSlug(),
|
|
$tempKey,
|
|
$postId
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 상한과 정확히 같은 개수는 통과한다 (경계).
|
|
*/
|
|
public function test_temp_key_link_at_limit_passes(): void
|
|
{
|
|
$postId = $this->createTestPost();
|
|
$tempKey = 'tk_'.str_repeat('b', 20);
|
|
$this->seedTempAttachments($tempKey, 5);
|
|
|
|
$linked = app(AttachmentService::class)->linkTempAttachmentsWithMove(
|
|
$this->getTestBoardSlug(),
|
|
$tempKey,
|
|
$postId
|
|
);
|
|
|
|
$this->assertSame(5, $linked);
|
|
}
|
|
|
|
/**
|
|
* 기존 첨부와 합산해 상한을 넘으면 차단된다 (수정 경로).
|
|
*/
|
|
public function test_existing_plus_new_over_limit_is_blocked(): void
|
|
{
|
|
$postId = $this->createTestPost();
|
|
$this->seedLinkedAttachments($postId, 4);
|
|
|
|
$tempKey = 'tk_'.str_repeat('c', 20);
|
|
$this->seedTempAttachments($tempKey, 2);
|
|
|
|
$this->expectException(AttachmentLimitExceededException::class);
|
|
|
|
app(AttachmentService::class)->linkTempAttachmentsWithMove(
|
|
$this->getTestBoardSlug(),
|
|
$tempKey,
|
|
$postId
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 기존 4 + 신규 1 = 5 는 통과한다 (경계).
|
|
*/
|
|
public function test_existing_plus_new_at_limit_passes(): void
|
|
{
|
|
$postId = $this->createTestPost();
|
|
$this->seedLinkedAttachments($postId, 4);
|
|
|
|
$tempKey = 'tk_'.str_repeat('d', 20);
|
|
$this->seedTempAttachments($tempKey, 1);
|
|
|
|
$linked = app(AttachmentService::class)->linkTempAttachmentsWithMove(
|
|
$this->getTestBoardSlug(),
|
|
$tempKey,
|
|
$postId
|
|
);
|
|
|
|
$this->assertSame(1, $linked);
|
|
}
|
|
|
|
/**
|
|
* attachment_ids 경로도 합산 판정된다.
|
|
*/
|
|
public function test_attachment_ids_over_limit_is_blocked(): void
|
|
{
|
|
$postId = $this->createTestPost();
|
|
$this->seedLinkedAttachments($postId, 5);
|
|
|
|
$this->expectException(AttachmentLimitExceededException::class);
|
|
|
|
app(AttachmentService::class)->assertAttachmentCountWithin(
|
|
$this->getTestBoardSlug(),
|
|
$postId,
|
|
1
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 관리자 수정 경로에서 상한 초과가 500 이 아닌 422 로 응답되는지 검증합니다.
|
|
*
|
|
* FormRequest 선차단은 UX 용이고 최종 불변조건은 Service 예외다. 그런데 관리자 수정
|
|
* 컨트롤러의 catch 가 게시글 *조회* try 에 붙어 있어, 예외를 실제로 던지는 저장 try 는
|
|
* 이 예외를 잡지 못하고 generic 500 으로 떨어졌다. 조회 mock 이 정상 반환하고 저장 mock 이
|
|
* 예외를 던지는 구성으로 그 매핑만 격리해 검증한다.
|
|
*/
|
|
public function test_admin_update_maps_attachment_limit_to_422(): void
|
|
{
|
|
$slug = $this->getTestBoardSlug();
|
|
$admin = $this->createAdminUser([
|
|
"sirsoft-board.{$slug}.admin.posts.write",
|
|
"sirsoft-board.{$slug}.admin.manage",
|
|
]);
|
|
|
|
$postId = $this->createTestPost(['user_id' => $admin->id]);
|
|
$post = Post::find($postId);
|
|
|
|
$mock = Mockery::mock(PostService::class);
|
|
$mock->shouldReceive('getPost')->andReturn($post);
|
|
$mock->shouldReceive('updatePost')
|
|
->andThrow(new AttachmentLimitExceededException(5, 6));
|
|
$this->app->instance(PostService::class, $mock);
|
|
|
|
$this->actingAs($admin)
|
|
->putJson("/api/modules/sirsoft-board/admin/board/{$slug}/posts/{$postId}", [
|
|
'title' => '상한 초과 수정 시도',
|
|
'content' => '본문은 최소 길이를 넘겨야 검증을 통과합니다.',
|
|
])
|
|
->assertStatus(422)
|
|
->assertJsonPath('errors.code', 'attachment_limit_exceeded');
|
|
}
|
|
|
|
/**
|
|
* 직접 업로드와 `attachment_ids` 를 섞어 보내도 합산 판정된다.
|
|
*
|
|
* 소스가 나뉘면 각 소스는 상한 이하인데 총합만 초과하는 조합이 만들어진다. 어느 한 소스만
|
|
* 세는 구현은 이 조합을 그대로 통과시킨다.
|
|
*/
|
|
public function test_mixed_files_and_attachment_ids_over_limit_is_blocked(): void
|
|
{
|
|
$postId = $this->createTestPost(['user_id' => $this->memberUser->id]);
|
|
|
|
// 기존 첨부 3 개 — 이 중 3 개를 attachment_ids 로 유지하면서 새 파일 3 개를 더 보낸다
|
|
$this->seedLinkedAttachments($postId, 3);
|
|
$keepIds = Attachment::where('post_id', $postId)->pluck('id')->all();
|
|
|
|
$response = $this->actingAs($this->memberUser)->putJson(
|
|
"/api/modules/sirsoft-board/boards/{$this->getTestBoardSlug()}/posts/{$postId}",
|
|
[
|
|
'title' => '혼합 소스 상한 초과',
|
|
'content' => '본문은 최소 길이를 넘겨야 검증을 통과합니다.',
|
|
'attachment_ids' => $keepIds,
|
|
'files' => [
|
|
UploadedFile::fake()->image('mix-1.png'),
|
|
UploadedFile::fake()->image('mix-2.png'),
|
|
UploadedFile::fake()->image('mix-3.png'),
|
|
],
|
|
]
|
|
);
|
|
|
|
$response->assertStatus(422);
|
|
$this->assertArrayHasKey('files', $response->json('errors') ?? []);
|
|
}
|
|
|
|
/**
|
|
* 작성 시 직접 업로드가 상한과 정확히 같은 개수면 통과하고 실제로 저장된다 (경계).
|
|
*
|
|
* 합산 판정이 들어간 뒤로 `files[]` 경로는 초과 케이스만 고정돼 있었다. 판정식이
|
|
* `>=` 로 어긋나거나 기존 첨부 0 건을 1 건으로 세면 상한 동수가 조용히 막히는데,
|
|
* 초과 케이스만 보는 테스트는 그 회귀를 통과시킨다. 저장 건수까지 단언해 "차단은
|
|
* 안 했지만 첨부도 안 됐다" 는 절반짜리 통과를 배제한다.
|
|
*/
|
|
public function test_files_at_limit_on_create_passes_and_persists(): void
|
|
{
|
|
$response = $this->actingAs($this->memberUser, 'sanctum')->postJson(
|
|
"/api/modules/sirsoft-board/boards/{$this->getTestBoardSlug()}/posts",
|
|
[
|
|
'title' => '상한 동수 첨부',
|
|
'content' => '본문은 최소 길이를 넘겨야 검증을 통과합니다.',
|
|
'files' => [
|
|
UploadedFile::fake()->image('at-1.png'),
|
|
UploadedFile::fake()->image('at-2.png'),
|
|
UploadedFile::fake()->image('at-3.png'),
|
|
UploadedFile::fake()->image('at-4.png'),
|
|
UploadedFile::fake()->image('at-5.png'),
|
|
],
|
|
]
|
|
);
|
|
|
|
$response->assertStatus(201);
|
|
|
|
$postId = (int) $response->json('data.id');
|
|
$this->assertSame(5, Attachment::where('post_id', $postId)->count());
|
|
}
|
|
|
|
/**
|
|
* 작성 시 상한을 1 건 넘기면 차단된다 (위 경계의 대조군).
|
|
*/
|
|
public function test_files_over_limit_on_create_is_blocked(): void
|
|
{
|
|
$response = $this->actingAs($this->memberUser, 'sanctum')->postJson(
|
|
"/api/modules/sirsoft-board/boards/{$this->getTestBoardSlug()}/posts",
|
|
[
|
|
'title' => '상한 초과 첨부',
|
|
'content' => '본문은 최소 길이를 넘겨야 검증을 통과합니다.',
|
|
'files' => [
|
|
UploadedFile::fake()->image('over-1.png'),
|
|
UploadedFile::fake()->image('over-2.png'),
|
|
UploadedFile::fake()->image('over-3.png'),
|
|
UploadedFile::fake()->image('over-4.png'),
|
|
UploadedFile::fake()->image('over-5.png'),
|
|
UploadedFile::fake()->image('over-6.png'),
|
|
],
|
|
]
|
|
);
|
|
|
|
$response->assertStatus(422);
|
|
$this->assertArrayHasKey('files', $response->json('errors') ?? []);
|
|
}
|
|
|
|
/**
|
|
* 단일 업로드를 반복해도 상한을 넘는 순간 차단된다.
|
|
*
|
|
* 한 번에 여러 개를 보내는 경로만 막으면, 한 개씩 N 번 부르는 경로로 그대로 우회된다.
|
|
*/
|
|
public function test_repeated_single_upload_is_blocked_at_the_limit(): void
|
|
{
|
|
$postId = $this->createTestPost();
|
|
$service = app(AttachmentService::class);
|
|
|
|
for ($i = 0; $i < 5; $i++) {
|
|
$service->upload(
|
|
$this->getTestBoardSlug(),
|
|
UploadedFile::fake()->image("single-{$i}.png"),
|
|
$postId
|
|
);
|
|
}
|
|
|
|
$this->assertSame(5, Attachment::where('post_id', $postId)->count());
|
|
|
|
$this->expectException(AttachmentLimitExceededException::class);
|
|
|
|
$service->upload(
|
|
$this->getTestBoardSlug(),
|
|
UploadedFile::fake()->image('single-6.png'),
|
|
$postId
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 단일 업로드 엔드포인트가 상한 초과를 422 로 돌려주는지 검증합니다.
|
|
*
|
|
* 이 엔드포인트는 게시글 작성 Request 들과 마찬가지로 요청 계층에서 선차단하고,
|
|
* `AttachmentService::upload()` 가 최종 불변조건으로 남는다(훅·Service 직접 호출 방어).
|
|
*
|
|
* 두 계층이 같은 응답을 내야 소비자가 분기하지 않는다 — 선차단은 필드 검증 오류가 아니라
|
|
* `AttachmentLimitExceededException` 을 던지고, 그 `render()` 가 컨트롤러 catch 와
|
|
* 동일한 `errors.code=attachment_limit_exceeded` 를 만든다. 이 테스트가 그 동일성을 고정한다.
|
|
*/
|
|
public function test_single_upload_endpoint_blocks_over_limit_with_422(): void
|
|
{
|
|
$slug = $this->getTestBoardSlug();
|
|
$postId = $this->createTestPost(['user_id' => $this->memberUser->id]);
|
|
|
|
$this->seedLinkedAttachments($postId, 5);
|
|
|
|
$this->actingAs($this->memberUser, 'sanctum')
|
|
->postJson("/api/modules/sirsoft-board/boards/{$slug}/attachments", [
|
|
'file' => UploadedFile::fake()->image('over-limit.png'),
|
|
'post_id' => $postId,
|
|
])
|
|
->assertStatus(422)
|
|
->assertJsonPath('errors.code', 'attachment_limit_exceeded');
|
|
}
|
|
|
|
/**
|
|
* 단일 업로드 상한 초과는 Service 에 닿기 전 요청 계층에서 차단된다.
|
|
*
|
|
* `AttachmentService` 를 통째로 mock 해 `upload()` 가 아예 호출되지 않음을 단언한다 —
|
|
* Service 가 최종 관문으로 남아 있어도 요청 계층이 먼저 막아야 선차단이라 부를 수 있다.
|
|
*/
|
|
public function test_single_upload_over_limit_is_blocked_before_reaching_service(): void
|
|
{
|
|
$slug = $this->getTestBoardSlug();
|
|
$postId = $this->createTestPost(['user_id' => $this->memberUser->id]);
|
|
|
|
$this->seedLinkedAttachments($postId, 5);
|
|
|
|
$service = Mockery::mock(AttachmentService::class);
|
|
$service->shouldNotReceive('upload');
|
|
$this->app->instance(AttachmentService::class, $service);
|
|
|
|
$this->actingAs($this->memberUser, 'sanctum')
|
|
->postJson("/api/modules/sirsoft-board/boards/{$slug}/attachments", [
|
|
'file' => UploadedFile::fake()->image('blocked-before-service.png'),
|
|
'post_id' => $postId,
|
|
])
|
|
->assertStatus(422)
|
|
->assertJsonPath('errors.code', 'attachment_limit_exceeded');
|
|
}
|
|
|
|
/**
|
|
* temp_key 임시 업로드도 상한을 넘는 순간 요청 계층에서 차단된다.
|
|
*/
|
|
public function test_single_upload_with_temp_key_over_limit_is_blocked(): void
|
|
{
|
|
$slug = $this->getTestBoardSlug();
|
|
$tempKey = 'temp-single-upload-limit';
|
|
|
|
$this->seedTempAttachments($tempKey, 5);
|
|
|
|
$this->actingAs($this->memberUser, 'sanctum')
|
|
->postJson("/api/modules/sirsoft-board/boards/{$slug}/attachments", [
|
|
'file' => UploadedFile::fake()->image('temp-over-limit.png'),
|
|
'temp_key' => $tempKey,
|
|
])
|
|
->assertStatus(422)
|
|
->assertJsonPath('errors.code', 'attachment_limit_exceeded');
|
|
}
|
|
|
|
/**
|
|
* 상한 이내 단일 업로드는 그대로 통과한다 (경계).
|
|
*/
|
|
public function test_single_upload_endpoint_allows_within_limit(): void
|
|
{
|
|
$slug = $this->getTestBoardSlug();
|
|
$postId = $this->createTestPost(['user_id' => $this->memberUser->id]);
|
|
|
|
$this->seedLinkedAttachments($postId, 4);
|
|
|
|
$this->actingAs($this->memberUser, 'sanctum')
|
|
->postJson("/api/modules/sirsoft-board/boards/{$slug}/attachments", [
|
|
'file' => UploadedFile::fake()->image('within-limit.png'),
|
|
'post_id' => $postId,
|
|
])
|
|
->assertSuccessful();
|
|
}
|
|
|
|
/**
|
|
* 수정 시 `attachment_ids` 로 보낸 첨부가 사용자 경로에서도 실제로 연결된다.
|
|
*
|
|
* 결함: 관리자 컨트롤러는 `attachment_ids` 를 뽑아 Service 로 넘기는데 사용자 컨트롤러는
|
|
* 넘기지 않았다. 그래서 같은 요청이 검증(형식·개수 상한 합산)은 통과하고 200 을 받는데
|
|
* 첨부만 조용히 연결되지 않았다 — 화면에는 성공으로 보이고 첨부만 사라진다.
|
|
* 작성 경로의 `files[]` 유실과 같은 계열이며, 두 경로가 같은 강도여야 한다.
|
|
*/
|
|
public function test_update_links_attachment_ids_on_user_path(): void
|
|
{
|
|
$postId = $this->createTestPost(['user_id' => $this->memberUser->id]);
|
|
|
|
$tempKey = 'tk_'.str_repeat('u', 20);
|
|
$this->seedTempAttachments($tempKey, 2);
|
|
$attachmentIds = Attachment::where('temp_key', $tempKey)->pluck('id')->all();
|
|
|
|
$response = $this->actingAs($this->memberUser, 'sanctum')->putJson(
|
|
"/api/modules/sirsoft-board/boards/{$this->getTestBoardSlug()}/posts/{$postId}",
|
|
[
|
|
'title' => '첨부 연결 수정',
|
|
'content' => '본문은 최소 길이를 넘겨야 검증을 통과합니다.',
|
|
'attachment_ids' => $attachmentIds,
|
|
]
|
|
);
|
|
|
|
$response->assertStatus(200);
|
|
|
|
$this->assertSame(
|
|
2,
|
|
Attachment::where('post_id', $postId)->count(),
|
|
'attachment_ids 로 보낸 첨부가 게시글에 연결되지 않았습니다 — 응답은 성공인데 첨부만 사라집니다.'
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 수정 경로의 `attachment_ids` 도 개수 상한을 넘기면 차단된다 (위 경계의 대조군).
|
|
*/
|
|
public function test_update_attachment_ids_over_limit_is_blocked(): void
|
|
{
|
|
$postId = $this->createTestPost(['user_id' => $this->memberUser->id]);
|
|
|
|
$tempKey = 'tk_'.str_repeat('o', 20);
|
|
$this->seedTempAttachments($tempKey, 6);
|
|
$attachmentIds = Attachment::where('temp_key', $tempKey)->pluck('id')->all();
|
|
|
|
$response = $this->actingAs($this->memberUser, 'sanctum')->putJson(
|
|
"/api/modules/sirsoft-board/boards/{$this->getTestBoardSlug()}/posts/{$postId}",
|
|
[
|
|
'title' => '상한 초과 첨부 수정',
|
|
'content' => '본문은 최소 길이를 넘겨야 검증을 통과합니다.',
|
|
'attachment_ids' => $attachmentIds,
|
|
]
|
|
);
|
|
|
|
$response->assertStatus(422);
|
|
$this->assertSame(0, Attachment::where('post_id', $postId)->count());
|
|
}
|
|
|
|
/**
|
|
* 상한 미설정(0) 게시판은 제한하지 않는다 (기존 동작 유지).
|
|
*/
|
|
public function test_board_without_limit_is_not_restricted(): void
|
|
{
|
|
$this->board->update(['max_file_count' => 0]);
|
|
|
|
$postId = $this->createTestPost();
|
|
$tempKey = 'tk_'.str_repeat('e', 20);
|
|
$this->seedTempAttachments($tempKey, 8);
|
|
|
|
$linked = app(AttachmentService::class)->linkTempAttachmentsWithMove(
|
|
$this->getTestBoardSlug(),
|
|
$tempKey,
|
|
$postId
|
|
);
|
|
|
|
$this->assertSame(8, $linked);
|
|
}
|
|
}
|