fix(page): 페이지·첨부 삭제를 물리 삭제로 전환해 슬러그 재사용 가능하도록 수정
페이지 삭제 후 동일 슬러그로 재생성 시 "이미 사용 중" 오류가 나던 문제 수정. 슬러그 중복 체크(SoftDeletes 스코프로 삭제 행 제외)와 생성 검증(Rule::unique로 삭제 행 포함)이 soft delete를 다르게 취급해 발생. Page/PageAttachment의 SoftDeletes를 제거하고 deleted_at 컬럼을 마이그레이션으로 정리해 두 경로를 정합화.
This commit is contained in:
@@ -22,6 +22,10 @@
|
||||
|
||||
- 페이지 등록 / 수정 화면의 입력 행 컨테이너를 sirsoft-admin_basic 표준 시맨틱 (.row-stack) 과 정합 — 행 사이 옅은 회색 구분선(1px) 이 자동 적용되어 페이지 폼의 행 묶음 가독성 개선.
|
||||
|
||||
### Fixed
|
||||
|
||||
- 페이지를 삭제한 뒤 같은 슬러그(주소)로 새 페이지를 다시 만들 수 있도록 수정했습니다. 이전에는 삭제한 페이지의 슬러그가 남아 "이미 사용 중"으로 표시되어 동일 주소로 페이지를 다시 만들 수 없었습니다. (#424 @gnuboard 님께서 제보해주셨습니다.)
|
||||
|
||||
## [1.0.0-beta.3] - 2026-05-11
|
||||
|
||||
### Changed
|
||||
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
if (! Schema::hasTable('pages') || ! Schema::hasColumn('pages', 'deleted_at')) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 잔존 soft-delete 페이지 행을 물리 삭제한다.
|
||||
// page_attachments.page_id 의 cascadeOnDelete FK 로 연관 첨부 행도 함께 제거된다.
|
||||
// (첨부 물리 파일은 페이지 삭제 시점에 이미 제거된 상태 — 여기서는 DB 행만 정리)
|
||||
DB::table('pages')->whereNotNull('deleted_at')->delete();
|
||||
|
||||
Schema::table('pages', function (Blueprint $table) {
|
||||
// 소프트 삭제 컬럼 제거 (페이지는 물리 삭제 방식으로 전환)
|
||||
$table->dropSoftDeletes();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
if (! Schema::hasTable('pages') || Schema::hasColumn('pages', 'deleted_at')) {
|
||||
return;
|
||||
}
|
||||
|
||||
Schema::table('pages', function (Blueprint $table) {
|
||||
// 소프트 삭제 컬럼 복원
|
||||
$table->softDeletes();
|
||||
});
|
||||
}
|
||||
};
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
if (! Schema::hasTable('page_attachments') || ! Schema::hasColumn('page_attachments', 'deleted_at')) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 잔존 soft-delete 첨부 행을 물리 삭제한다.
|
||||
// (물리 파일은 첨부 삭제 시점에 이미 제거된 상태 — 여기서는 DB 행만 정리)
|
||||
DB::table('page_attachments')->whereNotNull('deleted_at')->delete();
|
||||
|
||||
Schema::table('page_attachments', function (Blueprint $table) {
|
||||
// deleted_at 인덱스 제거 (소프트 삭제 미사용으로 불필요)
|
||||
$table->dropIndex(['deleted_at']);
|
||||
// 소프트 삭제 컬럼 제거 (첨부는 물리 삭제 방식으로 전환)
|
||||
$table->dropSoftDeletes();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
if (! Schema::hasTable('page_attachments') || Schema::hasColumn('page_attachments', 'deleted_at')) {
|
||||
return;
|
||||
}
|
||||
|
||||
Schema::table('page_attachments', function (Blueprint $table) {
|
||||
// 소프트 삭제 컬럼 복원
|
||||
$table->softDeletes()->comment('소프트 삭제 일시');
|
||||
// deleted_at 인덱스 복원
|
||||
$table->index('deleted_at');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -11,7 +11,6 @@ use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
use Laravel\Scout\Searchable;
|
||||
use Modules\Sirsoft\Page\Database\Factories\PageFactory;
|
||||
|
||||
@@ -19,7 +18,6 @@ class Page extends Model implements FulltextSearchable
|
||||
{
|
||||
use HasFactory;
|
||||
use Searchable;
|
||||
use SoftDeletes;
|
||||
|
||||
/** @var array<string, array> 활동 로그 추적 필드 */
|
||||
public static array $activityLogFields = [
|
||||
|
||||
@@ -6,13 +6,11 @@ use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
use Modules\Sirsoft\Page\Database\Factories\PageAttachmentFactory;
|
||||
|
||||
class PageAttachment extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
use SoftDeletes;
|
||||
|
||||
/**
|
||||
* 팩토리 클래스를 반환합니다.
|
||||
|
||||
@@ -174,7 +174,7 @@ class PageAttachmentControllerTest extends FeatureTestCase
|
||||
$response->assertStatus(200)
|
||||
->assertJsonPath('success', true);
|
||||
|
||||
$this->assertSoftDeleted('page_attachments', ['id' => $attachment->id]);
|
||||
$this->assertDatabaseMissing('page_attachments', ['id' => $attachment->id]);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -14,6 +14,7 @@ use App\Models\User;
|
||||
use App\Search\Engines\DatabaseFulltextEngine;
|
||||
use Laravel\Scout\EngineManager;
|
||||
use Modules\Sirsoft\Page\Models\Page;
|
||||
use Modules\Sirsoft\Page\Models\PageAttachment;
|
||||
use Modules\Sirsoft\Page\Models\PageVersion;
|
||||
use Modules\Sirsoft\Page\Tests\FeatureTestCase;
|
||||
|
||||
@@ -383,7 +384,7 @@ class PageControllerTest extends FeatureTestCase
|
||||
|
||||
$response->assertStatus(200);
|
||||
$this->assertDatabaseHas('pages', ['id' => $page->id, 'slug' => 'test-slug-new']);
|
||||
$this->assertDatabaseMissing('pages', ['slug' => 'test-slug-old', 'deleted_at' => null]);
|
||||
$this->assertDatabaseMissing('pages', ['slug' => 'test-slug-old']);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -593,7 +594,7 @@ class PageControllerTest extends FeatureTestCase
|
||||
// ─── 삭제 (destroy) ────────────────────────────────
|
||||
|
||||
/**
|
||||
* 페이지를 삭제(소프트 삭제)할 수 있는지 확인
|
||||
* 페이지를 삭제(물리 삭제)할 수 있는지 확인
|
||||
*/
|
||||
public function test_admin_can_delete_page(): void
|
||||
{
|
||||
@@ -609,8 +610,8 @@ class PageControllerTest extends FeatureTestCase
|
||||
$response->assertStatus(200)
|
||||
->assertJsonPath('success', true);
|
||||
|
||||
// 소프트 삭제 확인
|
||||
$this->assertSoftDeleted('pages', ['id' => $page->id]);
|
||||
// 물리 삭제 확인 (soft delete 미사용 — 행이 실제로 제거됨)
|
||||
$this->assertDatabaseMissing('pages', ['id' => $page->id]);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -624,6 +625,117 @@ class PageControllerTest extends FeatureTestCase
|
||||
$response->assertStatus(404);
|
||||
}
|
||||
|
||||
/**
|
||||
* 페이지 삭제 후 동일 슬러그로 재생성할 수 있는지 확인 (#424-20 회귀)
|
||||
*
|
||||
* soft delete 시절에는 삭제된 페이지의 slug 가 잔존하여 동일 slug 재생성 시
|
||||
* Rule::unique 가 422 를 반환했다. hard delete 전환으로 재생성이 성공해야 한다.
|
||||
*/
|
||||
public function test_can_recreate_page_with_same_slug_after_delete(): void
|
||||
{
|
||||
$slug = 'test-reuse-slug';
|
||||
|
||||
// 1) 최초 생성
|
||||
$first = $this->actingAs($this->adminUser)
|
||||
->postJson('/api/modules/sirsoft-page/admin/pages', [
|
||||
'slug' => $slug,
|
||||
'title' => ['ko' => '원본 페이지', 'en' => 'Original'],
|
||||
'content' => ['ko' => '본문', 'en' => 'Content'],
|
||||
'content_mode' => 'html',
|
||||
]);
|
||||
$first->assertStatus(201);
|
||||
$firstId = $first->json('data.id');
|
||||
|
||||
// 2) 삭제
|
||||
$this->actingAs($this->adminUser)
|
||||
->deleteJson("/api/modules/sirsoft-page/admin/pages/{$firstId}")
|
||||
->assertStatus(200);
|
||||
|
||||
$this->assertDatabaseMissing('pages', ['id' => $firstId]);
|
||||
|
||||
// 3) 동일 slug 로 재생성 — 성공해야 함
|
||||
$second = $this->actingAs($this->adminUser)
|
||||
->postJson('/api/modules/sirsoft-page/admin/pages', [
|
||||
'slug' => $slug,
|
||||
'title' => ['ko' => '재생성 페이지', 'en' => 'Recreated'],
|
||||
'content' => ['ko' => '본문', 'en' => 'Content'],
|
||||
'content_mode' => 'html',
|
||||
]);
|
||||
|
||||
$second->assertStatus(201)
|
||||
->assertJsonPath('data.slug', $slug);
|
||||
}
|
||||
|
||||
/**
|
||||
* 페이지 삭제 후 슬러그 중복 체크와 생성 검증 결과가 일치하는지 확인 (#424-20 회귀)
|
||||
*
|
||||
* 삭제 후 check-slug 는 "사용 가능", 생성도 성공해야 한다 (두 경로 정합).
|
||||
*/
|
||||
public function test_check_slug_and_create_are_consistent_after_delete(): void
|
||||
{
|
||||
$slug = 'test-consistent-slug';
|
||||
|
||||
$first = $this->actingAs($this->adminUser)
|
||||
->postJson('/api/modules/sirsoft-page/admin/pages', [
|
||||
'slug' => $slug,
|
||||
'title' => ['ko' => '원본', 'en' => 'Original'],
|
||||
'content_mode' => 'html',
|
||||
]);
|
||||
$first->assertStatus(201);
|
||||
$firstId = $first->json('data.id');
|
||||
|
||||
$this->actingAs($this->adminUser)
|
||||
->deleteJson("/api/modules/sirsoft-page/admin/pages/{$firstId}")
|
||||
->assertStatus(200);
|
||||
|
||||
// check-slug 는 사용 가능(exists=false)으로 응답
|
||||
$check = $this->actingAs($this->adminUser)
|
||||
->postJson('/api/modules/sirsoft-page/admin/pages/check-slug', [
|
||||
'slug' => $slug,
|
||||
]);
|
||||
$check->assertStatus(200)
|
||||
->assertJsonPath('data.exists', false);
|
||||
|
||||
// 생성도 성공 (check-slug 와 동일 결론)
|
||||
$create = $this->actingAs($this->adminUser)
|
||||
->postJson('/api/modules/sirsoft-page/admin/pages', [
|
||||
'slug' => $slug,
|
||||
'title' => ['ko' => '재생성', 'en' => 'Recreated'],
|
||||
'content_mode' => 'html',
|
||||
]);
|
||||
$create->assertStatus(201);
|
||||
}
|
||||
|
||||
/**
|
||||
* 첨부가 있는 페이지를 물리 삭제해도 삭제 흐름(첨부 정리 + 활동로그 기록)이 정상 완료되는지 확인 (#424-20)
|
||||
*
|
||||
* 활동로그 리스너는 삭제되는 페이지를 loggable 로 참조한다. hard delete 후 loggable 행이
|
||||
* 사라지더라도 삭제 응답이 200 이고 페이지/첨부 행이 모두 물리 삭제되어야 한다.
|
||||
*/
|
||||
public function test_deleting_page_with_attachment_completes_without_error(): void
|
||||
{
|
||||
$page = Page::factory()->create([
|
||||
'slug' => 'test-delete-with-attach',
|
||||
'created_by' => $this->adminUser->id,
|
||||
'updated_by' => $this->adminUser->id,
|
||||
]);
|
||||
|
||||
$attachment = PageAttachment::factory()->create([
|
||||
'page_id' => $page->id,
|
||||
'created_by' => $this->adminUser->id,
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($this->adminUser)
|
||||
->deleteJson("/api/modules/sirsoft-page/admin/pages/{$page->id}");
|
||||
|
||||
$response->assertStatus(200)
|
||||
->assertJsonPath('success', true);
|
||||
|
||||
// 페이지 + 첨부 모두 물리 삭제
|
||||
$this->assertDatabaseMissing('pages', ['id' => $page->id]);
|
||||
$this->assertDatabaseMissing('page_attachments', ['id' => $attachment->id]);
|
||||
}
|
||||
|
||||
// ─── 발행 토글 (publish) ───────────────────────────
|
||||
|
||||
/**
|
||||
|
||||
+1
-1
@@ -68,7 +68,7 @@ class PageRepositorySearchTest extends TestCase
|
||||
*/
|
||||
protected function tearDown(): void
|
||||
{
|
||||
Page::query()->withTrashed()->forceDelete();
|
||||
Page::query()->forceDelete();
|
||||
|
||||
if (isset($this->user)) {
|
||||
User::where('id', $this->user->id)->delete();
|
||||
|
||||
@@ -162,7 +162,7 @@ class PageAttachmentServiceTest extends ModuleTestCase
|
||||
/**
|
||||
* 첨부파일을 삭제할 수 있는지 확인
|
||||
*/
|
||||
public function test_delete_attachment_soft_deletes(): void
|
||||
public function test_delete_attachment_hard_deletes(): void
|
||||
{
|
||||
$page = Page::factory()->create([
|
||||
'slug' => 'test-svc-del-attach',
|
||||
@@ -176,7 +176,7 @@ class PageAttachmentServiceTest extends ModuleTestCase
|
||||
$result = $this->service->deleteAttachment($attachment);
|
||||
|
||||
$this->assertTrue($result);
|
||||
$this->assertSoftDeleted('page_attachments', ['id' => $attachment->id]);
|
||||
$this->assertDatabaseMissing('page_attachments', ['id' => $attachment->id]);
|
||||
}
|
||||
|
||||
// ─── reorder ───────────────────────────────────────
|
||||
|
||||
@@ -144,16 +144,16 @@ class PageServiceTest extends ModuleTestCase
|
||||
// ─── deletePage ────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 페이지를 소프트 삭제할 수 있는지 확인
|
||||
* 페이지를 물리 삭제할 수 있는지 확인
|
||||
*/
|
||||
public function test_delete_page_soft_deletes(): void
|
||||
public function test_delete_page_hard_deletes(): void
|
||||
{
|
||||
$page = $this->createTestPage('test-svc-delete');
|
||||
|
||||
$result = $this->service->deletePage($page);
|
||||
|
||||
$this->assertTrue($result);
|
||||
$this->assertSoftDeleted('pages', ['id' => $page->id]);
|
||||
$this->assertDatabaseMissing('pages', ['id' => $page->id]);
|
||||
}
|
||||
|
||||
// ─── changePublishStatus ───────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user