Files
HeuJung 3ebbda5d86 feat(board,ecommerce,pay,verification,templates,core): 문의 답변 사슬·답글 삭제 정책·검증 계약 + 실패 사유 표면화 전수
공개 (답변 중복 등록)/(문의 삭제 시 답변 잔존) 수정과 동형 결함 전수 해소.

- 문의 답변 단일성: 1차(피벗 is_answered) + 2차(게시판 리스너 duplicate 마커) 이중 방어,
 경합 경로에서도 사유(reply_already_exists 422)가 보존되도록 훅 반환 계약 확장
- 질문·답변·피벗 생명주기 대칭: 삭제/복원/영구삭제 훅 연동 + 고아 데이터 백필 업그레이드 스텝
- 게시판별 답글 삭제 정책(cascade/block) 신설, 정렬 화이트리스트·per_page 게이트
 (전량 조회 소비처 200/999 를 수용하는 상한 999 — 조였다가 관리자 화면 2곳 422 회귀 즉시 정정)
- 결제/본인인증 플러그인 검증 계약: FormRequest 15종 승격 + 컨트롤러 catch 정합.
 거래 조회 tid 상한은 저장 컬럼(varchar 100) 기준 — 해외 CBT TID(40자) 수용
- onError 토스트 고정 문구 전수 해소(67건/50파일/15확장) — 서버 실패 사유를 그대로 표면화,
 change-password 오변수(response→error) 교정
- 코어 플러그인 설정 검증 안내의 항목 표시명이 스키마 라벨(ko/en) 폴백에 덮여 요청 로케일
 (ja) 라벨을 잃던 회귀 정정 — 번역 등록이 있으면 그것을 우선 (7.0.7 미출시 기능 자체 결함)
- 사전 결함 정리(Icon 등가표 27·darkmode 2·validated 통일), API 문서·CHANGELOG·ja 팩 동기
- develop(60652fdc0) 리베이스: getTable 정비와 병합 — 양측 의도 보존 (충돌 18파일)
2026-08-17 17:04:44 +09:00

254 lines
10 KiB
PHP

<?php
namespace Modules\Sirsoft\Ecommerce\Tests\Unit\Upgrades;
use App\Extension\UpgradeContext;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Modules\Sirsoft\Ecommerce\Models\Product;
use Modules\Sirsoft\Ecommerce\Models\ProductInquiry;
use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase;
/**
* 1.1.1 문의 정합화 데이터 마이그레이션(04/05/06) 회귀 테스트
*
* - 04 SoftDeleteOrphanInquiryReplyPosts: 부모(질문)가 부재/삭제된 고아 답변 글 소프트 삭제
* - 05 SoftDeleteInquiryPivotsForTrashedQuestionPosts: 질문 Post 가 부재/삭제인데 살아있는 피벗 소프트 삭제
* - 06 UnmarkAnsweredInquiriesWithoutLiveReplies: 살아있는 답변이 없는 「답변완료」 피벗 정정
*
* 문의 게시판 판별(04)은 설정 파일 축이 아닌 「피벗 join 실데이터」 축으로 검증한다 —
* 테스트 DB 의 boards 테이블에는 설정 slug 에 대응하는 행이 없으므로 설정 축은 자연 무해하다.
*/
class InquiryOrphanReplyBackfillTest extends ModuleTestCase
{
private const POST_MODEL = 'Modules\\Sirsoft\\Board\\Models\\Post';
private const BOARD_ID = 7;
private object $orphanReplyStep;
private object $trashedQuestionPivotStep;
private object $unmarkAnsweredStep;
private UpgradeContext $context;
private Product $product;
protected function setUp(): void
{
parent::setUp();
$migrationsDir = dirname(__DIR__, 3).'/upgrades/data/1.1.1/migrations';
require_once $migrationsDir.'/04_SoftDeleteOrphanInquiryReplyPosts.php';
require_once $migrationsDir.'/05_SoftDeleteInquiryPivotsForTrashedQuestionPosts.php';
require_once $migrationsDir.'/06_UnmarkAnsweredInquiriesWithoutLiveReplies.php';
$namespace = 'App\\Upgrades\\Data\\Ext\\Modules\\SirsoftEcommerce\\V1_1_1\\Migrations\\';
$orphanClass = $namespace.'SoftDeleteOrphanInquiryReplyPosts';
$pivotClass = $namespace.'SoftDeleteInquiryPivotsForTrashedQuestionPosts';
$unmarkClass = $namespace.'UnmarkAnsweredInquiriesWithoutLiveReplies';
$this->orphanReplyStep = new $orphanClass;
$this->trashedQuestionPivotStep = new $pivotClass;
$this->unmarkAnsweredStep = new $unmarkClass;
$this->context = new UpgradeContext(
fromVersion: '1.1.0',
toVersion: '1.1.1',
currentStep: '1.1.1',
logChannel: 'extension-upgrade',
);
$this->product = Product::factory()->create();
}
protected function tearDown(): void
{
Carbon::setTestNow();
parent::tearDown();
}
/**
* 게시글 행을 생성합니다 (board_posts 직접 시드 — V-1 안전 스텝과 동일하게 raw DB).
*
* @param array $overrides 컬럼 오버라이드
* @return int 생성된 게시글 ID
*/
private function makeBoardPost(array $overrides = []): int
{
return (int) DB::table('board_posts')->insertGetId(array_merge([
'board_id' => self::BOARD_ID,
'title' => '상품 문의 글',
'content' => '상품 문의 내용입니다.',
'ip_address' => '127.0.0.1',
'status' => 'published',
'created_at' => now(),
'updated_at' => now(),
], $overrides));
}
/**
* 문의 피벗 행을 생성합니다 (inquirable_type 은 게시판 Post FQCN 고정).
*
* @param int $postId 질문 Post ID
* @param array $overrides 컬럼 오버라이드
* @return ProductInquiry 생성된 피벗
*/
private function makePivot(int $postId, array $overrides = []): ProductInquiry
{
return ProductInquiry::factory()->create(array_merge([
'product_id' => $this->product->id,
'inquirable_type' => self::POST_MODEL,
'inquirable_id' => $postId,
], $overrides));
}
/**
* 04 — 부모가 삭제/부재인 답변 글만 소프트 삭제하고, 부모가 살아있는 답변은 건드리지 않는다.
*/
public function test_04_soft_deletes_only_orphan_reply_posts(): void
{
// Given: 살아있는 질문 + 그 답변 (오탐 검증 대상)
$aliveQuestion = $this->makeBoardPost();
$aliveReply = $this->makeBoardPost(['parent_id' => $aliveQuestion, 'depth' => 1]);
// And: 소프트 삭제된 질문 + 잔존 답변 (고아 ①)
$trashedQuestion = $this->makeBoardPost([
'status' => 'deleted',
'deleted_at' => now()->subDay(),
]);
$orphanByTrashedParent = $this->makeBoardPost(['parent_id' => $trashedQuestion, 'depth' => 1]);
// And: 부모가 하드 삭제로 부재한 답변 (고아 ②)
$orphanByMissingParent = $this->makeBoardPost(['parent_id' => 999999, 'depth' => 1]);
// And: 문의 게시판 판별용 피벗 (피벗 join posts.board_id 축)
$this->makePivot($aliveQuestion);
// When: 04 실행
$this->orphanReplyStep->run($this->context);
// Then: 고아 답변 2건만 status=deleted + deleted_at 마킹
foreach ([$orphanByTrashedParent, $orphanByMissingParent] as $orphanId) {
$row = DB::table('board_posts')->where('id', $orphanId)->first();
$this->assertSame('deleted', $row->status, "고아 답변({$orphanId})은 deleted 상태여야 합니다.");
$this->assertNotNull($row->deleted_at, "고아 답변({$orphanId})은 소프트 삭제되어야 합니다.");
}
// And: 부모가 살아있는 답변과 질문 글은 오탐 없이 그대로
foreach ([$aliveQuestion, $aliveReply] as $aliveId) {
$row = DB::table('board_posts')->where('id', $aliveId)->first();
$this->assertSame('published', $row->status, "살아있는 글({$aliveId})은 건드리면 안 됩니다.");
$this->assertNull($row->deleted_at);
}
}
/**
* 04 — 재실행 멱등: 이미 마킹된 고아의 deleted_at 이 갱신되지 않는다.
*/
public function test_04_is_idempotent_on_rerun(): void
{
// Given: 고아 답변 1건 (부모 부재) + 판별용 피벗
$question = $this->makeBoardPost();
$orphan = $this->makeBoardPost(['parent_id' => 999999, 'depth' => 1]);
$this->makePivot($question);
// When: 1차 실행 (T1 시각)
$firstRunAt = Carbon::parse('2026-08-17 10:00:00');
Carbon::setTestNow($firstRunAt);
$this->orphanReplyStep->run($this->context);
$afterFirst = DB::table('board_posts')->where('id', $orphan)->value('deleted_at');
$this->assertNotNull($afterFirst);
// When: 시간이 흐른 뒤 2차 실행 (T2 시각)
Carbon::setTestNow($firstRunAt->copy()->addHour());
$this->orphanReplyStep->run($this->context);
// Then: deleted_at IS NULL 필터로 재실행이 0건 — 1차 마킹 시각 그대로
$this->assertSame(
$afterFirst,
DB::table('board_posts')->where('id', $orphan)->value('deleted_at'),
'재실행이 기존 소프트 삭제 시각을 덮어쓰면 안 됩니다 (멱등).'
);
}
/**
* 05 — 질문 Post 가 삭제/부재인 살아있는 피벗만 소프트 삭제한다.
*/
public function test_05_soft_deletes_pivots_whose_question_post_is_gone(): void
{
// Given: 살아있는 질문 + 그 피벗 (생존 대상)
$aliveQuestion = $this->makeBoardPost();
$alivePivot = $this->makePivot($aliveQuestion);
// And: 소프트 삭제된 질문 + 살아있는 피벗 (정리 대상 ①)
$trashedQuestion = $this->makeBoardPost([
'status' => 'deleted',
'deleted_at' => now()->subDay(),
]);
$pivotForTrashed = $this->makePivot($trashedQuestion);
// And: 하드 삭제로 부재한 질문을 가리키는 피벗 (정리 대상 ②)
$pivotForMissing = $this->makePivot(888888);
// When: 05 실행
$this->trashedQuestionPivotStep->run($this->context);
// Then: 질문이 사라진 피벗 2건만 소프트 삭제
$this->assertSoftDeleted('ecommerce_product_inquiries', ['id' => $pivotForTrashed->id]);
$this->assertSoftDeleted('ecommerce_product_inquiries', ['id' => $pivotForMissing->id]);
// And: 질문이 살아있는 피벗은 그대로
$this->assertNull(
ProductInquiry::withTrashed()->find($alivePivot->id)->deleted_at,
'질문 Post 가 살아있는 피벗은 건드리면 안 됩니다.'
);
}
/**
* 06 — 살아있는 답변이 없는 「답변완료」 피벗만 미답변으로 정정한다.
*/
public function test_06_unmarks_answered_inquiries_without_live_replies(): void
{
// Given: 답변이 소프트 삭제된 질문 + 답변완료 피벗 (정정 대상)
$questionWithoutReply = $this->makeBoardPost();
$this->makeBoardPost([
'parent_id' => $questionWithoutReply,
'depth' => 1,
'status' => 'deleted',
'deleted_at' => now()->subDay(),
]);
$staleAnswered = $this->makePivot($questionWithoutReply, [
'is_answered' => true,
'answered_at' => now()->subDays(2),
]);
// And: 살아있는 답변이 있는 질문 + 답변완료 피벗 (유지 대상)
$questionWithReply = $this->makeBoardPost();
$this->makeBoardPost(['parent_id' => $questionWithReply, 'depth' => 1]);
$validAnswered = $this->makePivot($questionWithReply, [
'is_answered' => true,
'answered_at' => now()->subDays(2),
]);
// When: 06 실행
$this->unmarkAnsweredStep->run($this->context);
// Then: 답변 없는 답변완료만 해제 (is_answered=0 + answered_at=null)
$this->assertDatabaseHas('ecommerce_product_inquiries', [
'id' => $staleAnswered->id,
'is_answered' => false,
'answered_at' => null,
]);
// And: 살아있는 답변이 있는 피벗은 답변완료 유지
$kept = ProductInquiry::find($validAnswered->id);
$this->assertTrue($kept->is_answered, '살아있는 답변이 있으면 답변완료를 유지해야 합니다.');
$this->assertNotNull($kept->answered_at);
}
}