fix(ecommerce,board,tosspayments,pay_kginicis,pay_nhnkcp,pay_nicepayments,core,basic): 공개 이슈 잔여 결함 5건 해소 — 상점 주소 SSoT·게시판 계약/안내/상한/색인
공개 이슈(gnuboard/g7) 17건 전수 대조에서 "진짜 누락"으로 판정된 5건을 해소한다.
— 서버가 만들어 내보내는 상점 주소가 각 지점에서 `/shop` 리터럴이나
`"/{$routePath}/..."` 조립으로 흩어져 있었다. 기본값이 아닌 상점에서는 그 주소가
존재하지 않는 화면을 가리키는데, 서버는 문자열을 만들어 내려보냈을 뿐이라 예외도
404 로그도 남지 않는다 — 링크만 조용히 죽는다. `ShopPathResolver` 를 단일 해석
지점으로 두고 주문 완료 이동·비회원 조회 안내·알림 메일·통합검색·상품문의·SEO
캐시·사이트맵 전 지점을 경유시켰다. 같은 값에 종속된 PG 4종 리다이렉트는
`{shopBase}` 자리표시자로, 편집기 상태 scope 는 선택 세그먼트 토큰 `/*?`
(engine-v1.58.0)로 해소했다. 기설치본은 업그레이드 스텝이 백필한다.
M-02 — 답변 깊이 상한이 config 는 1~10 인데 컬럼 comment 만 1~5 로 남아,
comment 에서 자동 생성되는 계약 문서 4곳이 실제보다 좁은 상한을 공개하고 있었다.
L-01 — 관리자 게시글 폼에 첨부 안내가 아예 없었고, 대신 사장된 고정 문구
("최대 5개 파일, 각 10MB")가 남아 있었다. 게시판마다 다른 실제 설정값을 렌더한다.
— 목록 1페이지가 공지 전량을 상한 없이 읽던 것에 안전상한을 두고(구조 무변경,
limit 부가만), 조회수 정렬 목록 술어를 덮는 복합 색인을 신설했다.
브라우저 실측 검수에서 발견한 사전 결함 1건(사용자 게시판 목록이 URL 정렬
파라미터를 데이터소스로 전달하지 않던 문제)도 함께 고쳤다. 폴백을 빈 문자열로 둬
URL 에 정렬이 없을 때 게시판 설정을 덮어쓰지 않게 했다.
This commit is contained in:
1 parent
3715be5e51
commit
f9c4c025cb
138 files changed
+3907
-361
No files matched your search
@@ -26,6 +26,9 @@
|
||||
- 게시글 목록을 뒤쪽 페이지에서도 빠르게 열 수 있도록 조회 방식을 바꿨습니다. 예전에는 페이지가 뒤로 갈수록 건너뛰는 게시글의 본문 앞부분까지 함께 읽어 느려졌지만, 이제 현재 페이지의 게시글만 본문 미리보기를 읽습니다. 공지 노출·답글 표시·다음 페이지 버튼은 이전과 동일하게 동작합니다. (#74 @jiwonpapa 님께서 제보해주셨습니다.)
|
||||
- 신고 관리 목록도 같은 방식으로 개선했습니다. 신고 건수·대상 글 상태처럼 목록에 함께 표시되는 정보를 예전에는 건너뛰는 신고까지 모두 계산했지만, 이제 현재 페이지의 신고에 대해서만 계산합니다. 신고가 많이 쌓인 사이트에서 목록 페이지 이동이 빨라집니다. (#74 @jiwonpapa 님께서 제보해주셨습니다.)
|
||||
- 마이페이지의 「내가 쓴 댓글」·「내가 댓글 단 글」 목록도 같은 방식으로 개선했습니다. 각 글의 최근 댓글을 예전에는 건너뛰는 글까지 모두 찾아봤지만, 이제 현재 페이지의 글에 대해서만 찾습니다. 댓글 활동이 많은 회원의 목록 페이지 이동이 빨라집니다. (#74 @jiwonpapa 님께서 제보해주셨습니다.)
|
||||
- 게시글 목록을 조회순으로 볼 때도 뒤쪽 페이지가 빠르게 열리도록 색인을 정비했습니다. 게시판 설정의 기본 정렬을 조회순으로 두었거나 목록에서 조회순으로 바꿔 보는 경우가 해당됩니다. 기존 사이트도 업데이트 시 자동으로 반영되며, 게시글이 아주 많은 경우 이 과정에 수 분이 걸리고 그동안 글쓰기가 잠시 대기할 수 있습니다. (#74 @jiwonpapa 님께서 제보해주셨습니다.)
|
||||
- 목록 첫 페이지의 공지를 개수 제한 없이 모두 읽던 것에 안전 상한을 두었습니다. 공지는 관리자가 등록하는 글이라 보통 문제가 되지 않지만, 어떤 이유로든 공지가 많이 쌓인 게시판에서 목록을 여는 것만으로 부담이 커지던 여지를 없앴습니다. 상한에 걸리면 최근 공지가 우선 표시됩니다. (#74 @jiwonpapa 님께서 제보해주셨습니다.)
|
||||
- 답변글 최대 깊이의 안내 상한 표기를 실제 설정 가능 범위(1~10)에 맞게 정정했습니다. 실제로는 10까지 설정할 수 있는데 일부 안내에만 5까지로 적혀 있었습니다. (#81 @jiwonpapa 님께서 제보해주셨습니다.)
|
||||
- 게시글이 많은 게시판에서 목록 정렬이 더 빨라지도록 색인을 정비했습니다. 신고 관리 목록에도 같은 정비를 적용했습니다. 같은 시각에 등록된 게시글이 많은 구간에서 순서를 정하느라 생기던 추가 작업이 사라집니다. 기존 사이트도 업데이트 시 자동으로 반영되며, 게시글이 아주 많은 경우 이 과정에 수 분이 걸리고 그동안 글쓰기가 잠시 대기할 수 있습니다.
|
||||
- 관리자 게시판 목록이 게시판마다 매니저·스텝 역할과 그 역할에 속한 회원 명단(이름·이메일)을 함께 내려주지 않습니다. 목록 화면이 쓰지 않는 값이라 게시판 수만큼 늘어나던 조회와 전송량이 사라집니다. 역할 지정은 종전대로 게시판 상세·설정 화면에서 하며, 목록에 보이는 항목은 이전과 동일합니다. (#76 @jordy-bitree 님께서 제보해주셨습니다.)
|
||||
- 한 글의 댓글이 아주 많아 목록이 일정 수에서 끊긴 경우, 끊겼다는 사실과 전체 건수를 함께 알려 줍니다. 예전에는 조용히 잘려 "댓글이 그만큼뿐" 으로 보였습니다.
|
||||
@@ -43,6 +46,7 @@
|
||||
### Fixed
|
||||
|
||||
- 게시판 알림 설정 화면에서 모든 알림의 제목·수신자·활성 여부가 비어 보이고 「이 채널에 대한 템플릿이 없습니다」로만 표시되던 문제를 수정했습니다. 편집 창을 열어도 내용이 채워지지 않았습니다. 채널을 전환하거나 새로고침해도 마찬가지였습니다. (#76 @jordy-bitree 님께서 제보해주셨습니다.)
|
||||
- 관리자 게시글 작성·수정 화면의 첨부 영역에 첨부 가능한 개수와 파일당 용량 안내가 표시됩니다. 안내는 그 게시판에 설정된 값을 그대로 보여 주므로, 게시판마다 다른 제한이 화면에 정확히 나타납니다. 이전에는 안내가 아예 없어 저장을 시도한 뒤에야 제한을 알 수 있었습니다. (#81 @jiwonpapa 님께서 제보해주셨습니다.)
|
||||
- 게시판 설정의 '새 글 표시 시간'이 저장 직후에는 숫자가 아닌 형태로 다뤄져, 조회 시점에 따라 값의 형태가 달라지던 문제를 수정했습니다. 새 글 표시 여부 판정에 쓰이는 값이므로 항상 숫자로 처리합니다.
|
||||
- 게시글이 아주 많은 게시판에서 목록의 글 번호가 0 이나 음수로 표시되던 문제를 수정했습니다. 총 건수를 끝까지 세지 못하는 경우에는 번호를 지어내지 않고 「-」로 표시합니다.
|
||||
- 통합 검색 결과에서 작성자 자리에 번역되지 않은 내부 문구가 그대로 보이던 문제를 수정했습니다. 이제 「비회원」으로 표시됩니다.
|
||||
|
||||
+1
-1
@@ -29,7 +29,7 @@ return new class extends Migration
|
||||
$table->enum('secret_mode', ['disabled', 'enabled', 'always'])->default('disabled')->comment('비밀글 설정 (disabled: 사용안함, enabled: 사용함, always: 고정)');
|
||||
$table->boolean('use_comment')->default(true)->comment('댓글 기능 사용');
|
||||
$table->boolean('use_reply')->default(true)->comment('게시글 답변 기능 사용 (댓글에 대한 답글 아님)');
|
||||
$table->unsignedSmallInteger('max_reply_depth')->default(5)->comment('답변글 최대 깊이 (1~5)');
|
||||
$table->unsignedSmallInteger('max_reply_depth')->default(5)->comment('답변글 최대 깊이 (1~10)');
|
||||
$table->boolean('use_report')->default(false)->comment('게시글/댓글 신고 기능 사용');
|
||||
$table->unsignedInteger('new_display_hours')->default(24)->comment('신규 게시글 표시 기간 (시간 단위)');
|
||||
$table->unsignedInteger('min_title_length')->default(2)->comment('최소 제목 글자 수');
|
||||
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*
|
||||
* boards.max_reply_depth 컬럼 comment 의 상한 표기를 실제 상한과 맞춥니다.
|
||||
*
|
||||
* 실제 허용 범위의 SSoT 는 `config/board.php` 의 `max_reply_depth_min=1` /
|
||||
* `max_reply_depth_max=10` 이며, 설정 화면과 검증 규칙이 이 값을 쓴다. 그런데 컬럼
|
||||
* comment 만 `(1~5)` 로 남아 있었고, API 레퍼런스 문서가 컬럼 comment 에서 자동
|
||||
* 생성되므로(`ColumnCommentResolver`) 계약 문서 네 곳이 실제보다 좁은 상한을
|
||||
* 공개하고 있었다 — 6~10 을 넣어도 저장되는데 문서는 5 까지라고 말한다.
|
||||
*
|
||||
* 데이터는 바꾸지 않는다. 타입·기본값·NULL 허용은 원 정의와 동일하게 유지하고
|
||||
* comment 만 교체한다.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
if (! Schema::hasTable('boards')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$prefix = DB::getTablePrefix();
|
||||
|
||||
DB::statement(
|
||||
"ALTER TABLE {$prefix}boards MODIFY COLUMN max_reply_depth "
|
||||
.'SMALLINT UNSIGNED NOT NULL DEFAULT 5 '
|
||||
."COMMENT '답변글 최대 깊이 (1~10)'"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*
|
||||
* comment 표기만 원복한다 (데이터·타입·기본값 무변경).
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
if (! Schema::hasTable('boards')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$prefix = DB::getTablePrefix();
|
||||
|
||||
DB::statement(
|
||||
"ALTER TABLE {$prefix}boards MODIFY COLUMN max_reply_depth "
|
||||
.'SMALLINT UNSIGNED NOT NULL DEFAULT 5 '
|
||||
."COMMENT '답변글 최대 깊이 (1~5)'"
|
||||
);
|
||||
}
|
||||
};
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* 게시글 목록의 조회수 정렬 색인을 추가합니다.
|
||||
*
|
||||
* 조회수 정렬은 화면에서 실제로 도달 가능한 경로다 — 게시판 설정의 기본 정렬
|
||||
* (`order_by = 'view_count'`)과 목록 URL(`?sort_by=view_count`) 둘 다 있고, 저장소
|
||||
* 정렬 화이트리스트에도 들어 있다. 그런데 목록 술어(`board_id` + `is_notice=0`
|
||||
* + `parent_id IS NULL` + `deleted_at IS NULL`)를 등치 사슬로 덮으면서 `view_count`
|
||||
* 로 끝나는 색인이 없어, 뒤쪽 페이지가 테이블 전체를 훑고 정렬했다.
|
||||
*
|
||||
* 필요한 색인은 계측 프로파일 선언(filters / order / soft_delete)에서 도출한 것이며,
|
||||
* `ListIndexCoverageTest` 가 같은 규칙으로 전 목록을 검사한다.
|
||||
*
|
||||
* 기존 `idx_board_posts_board_view_count (board_id, view_count)` 는 지우지 않는다 —
|
||||
* 인기글 조회(`getPopularPosts`)가 쓰는 색인이며, 이 목록 색인은 앞쪽에 등치 컬럼이
|
||||
* 더 붙어 있어 `(board_id, view_count)` 만 필요한 그 쿼리를 대신하지 못한다.
|
||||
* (`2026_04_17_000004` 의 주석이 `USE INDEX` 힌트를 언급하나 현재 코드에 힌트는 없다.)
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
private const TABLE = 'board_posts';
|
||||
|
||||
private const INDEX_NAME = 'idx_board_posts_list_views';
|
||||
|
||||
/** @var array<int, string> */
|
||||
private const INDEX_COLUMNS = ['board_id', 'is_notice', 'parent_id', 'deleted_at', 'view_count', 'id'];
|
||||
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
if (! Schema::hasTable(self::TABLE)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$existing = array_column(Schema::getIndexes(self::TABLE), 'name');
|
||||
|
||||
Schema::table(self::TABLE, function (Blueprint $table) use ($existing) {
|
||||
if (! in_array(self::INDEX_NAME, $existing, true)) {
|
||||
$table->index(self::INDEX_COLUMNS, self::INDEX_NAME);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
if (! Schema::hasTable(self::TABLE)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$existing = array_column(Schema::getIndexes(self::TABLE), 'name');
|
||||
|
||||
Schema::table(self::TABLE, function (Blueprint $table) use ($existing) {
|
||||
if (in_array(self::INDEX_NAME, $existing, true)) {
|
||||
$table->dropIndex(self::INDEX_NAME);
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -389,7 +389,7 @@ _단건 응답: `data` 객체의 필드._
|
||||
| secret_mode | string | `disabled` | 비밀글 설정 (disabled: 사용안함, enabled: 사용함, always: 고정) |
|
||||
| use_comment | boolean | `true` | 댓글 기능 사용 |
|
||||
| use_reply | boolean | `true` | 게시글 답변 기능 사용 (댓글에 대한 답글 아님) |
|
||||
| max_reply_depth | integer | `5` | 답변글 최대 깊이 (1~5) |
|
||||
| max_reply_depth | integer | `5` | 답변글 최대 깊이 (1~10) |
|
||||
| max_comment_depth | integer | `10` | 대댓글 최대 깊이 (1~10) |
|
||||
| comment_order | string | `ASC` | 댓글 정렬 순서 (ASC: 오름차순, DESC: 내림차순) |
|
||||
| show_view_count | boolean | `true` | 조회수 노출 |
|
||||
@@ -754,7 +754,7 @@ _단건 응답: `data` 객체의 필드._
|
||||
| secret_mode | string | `disabled` | 비밀글 설정 (disabled: 사용안함, enabled: 사용함, always: 고정) |
|
||||
| use_comment | boolean | `true` | 댓글 기능 사용 |
|
||||
| use_reply | boolean | `true` | 게시글 답변 기능 사용 (댓글에 대한 답글 아님) |
|
||||
| max_reply_depth | integer | `5` | 답변글 최대 깊이 (1~5) |
|
||||
| max_reply_depth | integer | `5` | 답변글 최대 깊이 (1~10) |
|
||||
| use_report | boolean | `true` | 게시글/댓글 신고 기능 사용 |
|
||||
| comment_order | string | `ASC` | 댓글 정렬 순서 (ASC: 오름차순, DESC: 내림차순) |
|
||||
| max_comment_depth | integer | `10` | 대댓글 최대 깊이 (1~10) |
|
||||
@@ -972,7 +972,7 @@ _단건 응답: `data` 객체의 필드._
|
||||
| secret_mode | string | `disabled` | 비밀글 설정 (disabled: 사용안함, enabled: 사용함, always: 고정) |
|
||||
| use_comment | boolean | `true` | 댓글 기능 사용 |
|
||||
| use_reply | boolean | `true` | 게시글 답변 기능 사용 (댓글에 대한 답글 아님) |
|
||||
| max_reply_depth | integer | `5` | 답변글 최대 깊이 (1~5) |
|
||||
| max_reply_depth | integer | `5` | 답변글 최대 깊이 (1~10) |
|
||||
| use_report | boolean | `true` | 게시글/댓글 신고 기능 사용 |
|
||||
| comment_order | string | `ASC` | 댓글 정렬 순서 (ASC: 오름차순, DESC: 내림차순) |
|
||||
| max_comment_depth | integer | `10` | 대댓글 최대 깊이 (1~10) |
|
||||
@@ -1979,7 +1979,7 @@ _단건 응답: `data` 객체의 필드._
|
||||
| secret_mode | string | `disabled` | 비밀글 설정 (disabled: 사용안함, enabled: 사용함, always: 고정) |
|
||||
| use_comment | boolean | `true` | 댓글 기능 사용 |
|
||||
| use_reply | boolean | `true` | 게시글 답변 기능 사용 (댓글에 대한 답글 아님) |
|
||||
| max_reply_depth | integer | `5` | 답변글 최대 깊이 (1~5) |
|
||||
| max_reply_depth | integer | `5` | 답변글 최대 깊이 (1~10) |
|
||||
| use_report | boolean | `true` | 게시글/댓글 신고 기능 사용 |
|
||||
| comment_order | string | `ASC` | 댓글 정렬 순서 (ASC: 오름차순, DESC: 내림차순) |
|
||||
| max_comment_depth | integer | `10` | 대댓글 최대 깊이 (1~10) |
|
||||
|
||||
@@ -880,6 +880,26 @@ class Module extends AbstractModule
|
||||
'seed_overrides' => ['board_id' => 1, 'is_notice' => 0, 'parent_id' => null],
|
||||
'soft_delete' => true,
|
||||
],
|
||||
'board_posts_by_view_count' => [
|
||||
'type' => 'list',
|
||||
'label' => '게시글 목록 (조회순)',
|
||||
'table' => 'board_posts',
|
||||
'columns' => [
|
||||
'id', 'board_id', 'user_id', 'parent_id', 'category',
|
||||
'title', 'author_name', 'content_mode',
|
||||
'is_notice', 'is_secret', 'status', 'depth',
|
||||
'view_count', 'comments_count', 'replies_count', 'attachments_count',
|
||||
'trigger_type', 'ip_address', 'created_at', 'updated_at', 'deleted_at',
|
||||
],
|
||||
// 조회수 정렬은 화면에서 실제로 도달 가능한 경로다 — 게시판 설정
|
||||
// `order_by='view_count'` 와 목록 URL `?sort_by=view_count` 둘 다 있고,
|
||||
// 저장소 정렬 화이트리스트에도 들어 있다. 작성일 정렬과 같은 술어에
|
||||
// 정렬 축만 다르므로 별도 프로파일로 재야 인덱스 커버리지가 판정된다.
|
||||
'order' => [['view_count', 'desc'], ['id', 'desc']],
|
||||
'filters' => ['board_id' => 1, 'is_notice' => 0, 'parent_id' => null],
|
||||
'seed_overrides' => ['board_id' => 1, 'is_notice' => 0, 'parent_id' => null],
|
||||
'soft_delete' => true,
|
||||
],
|
||||
// 댓글은 계측 프로파일을 두지 않는다. 페이지네이션되는 댓글 목록은 회원 본인 댓글
|
||||
// 목록뿐이고(CommentRepository), 그 쿼리에는 회원 스코프 · 삭제 게시글 제외
|
||||
// (whereExists 서브쿼리) · 비활성 게시판 제외가 무조건 붙는다. 선언형 필터로
|
||||
|
||||
+142
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* @file admin-board-post-form-attachments-desc.test.tsx
|
||||
* @description 관리자 게시글 폼 첨부 안내가 게시판 설정값으로 렌더되는지 검증 (공개 #81 L-01)
|
||||
*
|
||||
* 결함: 관리자 게시글 폼의 첨부 영역에는 안내 문구가 아예 없었고, 대신 사장된 lang 키
|
||||
* ("최대 5개 파일, 각 10MB 이하")가 남아 있었다. 그 문구는 어디에도 렌더되지 않는데,
|
||||
* 설령 렌더되었어도 게시판마다 다른 실제 제한과 무관한 고정 숫자였다.
|
||||
*
|
||||
* 운영자는 게시판 설정에서 첨부 개수·용량을 바꿀 수 있으므로, 안내는 반드시
|
||||
* `form_meta.data.board.max_file_count/max_file_size` 를 파라미터로 받아야 한다.
|
||||
* 유저 폼(`_post_form.json` 의 `board.form.attachments_desc`)이 이미 그 형태다.
|
||||
*
|
||||
* @scenario surface=admin_post_form, source=board_settings
|
||||
* @effects admin_attachment_hint_renders_board_limits
|
||||
*
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
|
||||
import {
|
||||
createLayoutTest,
|
||||
createMockComponentRegistryWithBasics,
|
||||
screen,
|
||||
} from '@core/template-engine/__tests__/utils/layoutTestUtils';
|
||||
|
||||
import attachmentsPartial from '../../../layouts/admin/partials/admin_board_post_form/_attachments.json';
|
||||
import koPosts from '../../../lang/partial/ko/admin/posts.json';
|
||||
|
||||
/** 안내 노드 id (레이아웃 JSON) */
|
||||
const DESC_ID = 'attachments_desc';
|
||||
|
||||
/**
|
||||
* partial 트리에서 id 로 노드를 찾습니다.
|
||||
*
|
||||
* @param node 순회 시작 노드
|
||||
* @param id 찾을 노드 id
|
||||
* @returns 찾은 노드 (없으면 null)
|
||||
*/
|
||||
function findById(node: any, id: string): any | null {
|
||||
if (!node || typeof node !== 'object') return null;
|
||||
if (node.id === id) return node;
|
||||
for (const child of node.children ?? []) {
|
||||
const found = findById(child, id);
|
||||
if (found) return found;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
describe('관리자 첨부 안내 — 선언 (#81 L-01)', () => {
|
||||
it('안내 노드가 존재하고 게시판 설정값을 파라미터로 넘긴다', () => {
|
||||
const node = findById(attachmentsPartial, DESC_ID);
|
||||
|
||||
expect(node, '첨부 안내 노드가 없다').not.toBeNull();
|
||||
expect(node.text).toContain('$t:sirsoft-board.admin.posts.form.attachments_desc');
|
||||
expect(node.text).toContain('maxFiles={{form_meta?.data?.board?.max_file_count');
|
||||
expect(node.text).toContain('maxSize={{form_meta?.data?.board?.max_file_size');
|
||||
});
|
||||
|
||||
it('다크 모드 variant 를 함께 지정한다', () => {
|
||||
const node = findById(attachmentsPartial, DESC_ID);
|
||||
|
||||
expect(node.props.className).toMatch(/text-gray-\d+/);
|
||||
expect(node.props.className).toMatch(/dark:text-gray-\d+/);
|
||||
});
|
||||
|
||||
it('lang 키가 ko 에 정의되어 있고 파라미터를 포함한다', () => {
|
||||
const value = (koPosts as any).form?.attachments_desc;
|
||||
|
||||
expect(value, 'attachments_desc 키가 없다').toBeTruthy();
|
||||
expect(value).toContain('{{maxFiles}}');
|
||||
expect(value).toContain('{{maxSize}}');
|
||||
});
|
||||
|
||||
it('사장된 고정 숫자 안내 키가 남아 있지 않다', () => {
|
||||
expect((koPosts as any).form?.attachments_limit).toBeUndefined();
|
||||
expect((koPosts as any).form?.max_10mb).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('관리자 첨부 안내 — 렌더 결과 (#81 L-01)', () => {
|
||||
afterEach(() => {
|
||||
window.history.replaceState({}, '', '/');
|
||||
});
|
||||
|
||||
/**
|
||||
* 안내 노드만 떼어내 게시판 설정값과 함께 렌더합니다.
|
||||
*
|
||||
* FileUploader 는 composite 라 mock 레지스트리에 없으므로, 검증 대상인 안내 노드만
|
||||
* 실제 레이아웃 JSON 에서 가져와 렌더한다 (문구 자체는 실 JSON 선언 그대로다).
|
||||
*
|
||||
* @param board 게시판 설정 (max_file_count / max_file_size)
|
||||
* @returns 렌더 유틸
|
||||
*/
|
||||
async function renderDesc(board: { max_file_count: number; max_file_size: number }) {
|
||||
const node = findById(attachmentsPartial, DESC_ID);
|
||||
|
||||
const utils = createLayoutTest(
|
||||
{
|
||||
version: '1.0.0',
|
||||
layout_name: 'admin_attachments_desc_probe',
|
||||
data_sources: [],
|
||||
components: [{ ...node, props: { ...node.props, 'data-testid': DESC_ID } }],
|
||||
} as never,
|
||||
{
|
||||
componentRegistry: createMockComponentRegistryWithBasics() as never,
|
||||
locale: 'ko',
|
||||
templateId: 'test-template',
|
||||
translations: {
|
||||
'sirsoft-board': { admin: { posts: { form: { attachments_desc: (koPosts as any).form.attachments_desc } } } },
|
||||
},
|
||||
initialData: { form_meta: { data: { board } } },
|
||||
}
|
||||
);
|
||||
|
||||
await utils.render();
|
||||
|
||||
return utils;
|
||||
}
|
||||
|
||||
it('게시판이 3개·50MB 로 설정되어 있으면 그 값이 안내에 나온다', async () => {
|
||||
const utils = await renderDesc({ max_file_count: 3, max_file_size: 50 });
|
||||
|
||||
// 존재를 먼저 확정한 뒤에야 문구를 단언한다 (부재 단언은 렌더 전에도 통과한다)
|
||||
const el = await screen.findByTestId(DESC_ID);
|
||||
expect(document.body.contains(el)).toBe(true);
|
||||
expect(el.textContent).toBe('최대 3개, 각 50MB 이하');
|
||||
|
||||
utils.cleanup();
|
||||
});
|
||||
|
||||
it('게시판 설정이 바뀌면 안내도 따라 바뀐다 (고정 문구가 아니다)', async () => {
|
||||
const utils = await renderDesc({ max_file_count: 10, max_file_size: 2 });
|
||||
|
||||
const el = await screen.findByTestId(DESC_ID);
|
||||
expect(el.textContent).toBe('최대 10개, 각 2MB 이하');
|
||||
// 종전 사장 키의 고정 숫자가 다시 새어 나오지 않는다
|
||||
expect(el.textContent).not.toContain('5개');
|
||||
|
||||
utils.cleanup();
|
||||
});
|
||||
});
|
||||
@@ -716,12 +716,11 @@
|
||||
"html_preview_warning": "HTML Rendering Warning",
|
||||
"no_content_preview": "Preview will appear when you enter content",
|
||||
"attachments": "Attachments",
|
||||
"attachments_desc": "Max {{maxFiles}} files, {{maxSize}}MB each",
|
||||
"attached_files": "Attached Files",
|
||||
"confirm_remove_attachment_title": "Delete Attachment",
|
||||
"confirm_remove_attachment_message": "The file will be deleted immediately. This action cannot be undone regardless of saving the post.",
|
||||
"attachments_limit": "Max 5 files, 10MB each (jpg, jpeg, png, gif, pdf, zip)",
|
||||
"drop_files_or_click": "Click to upload or drag and drop",
|
||||
"max_10mb": "Max 10MB",
|
||||
"file_upload_component_needed": "FileUpload component needed",
|
||||
"uploading": "Uploading...",
|
||||
"reply_to": "Reply to the following post",
|
||||
@@ -902,7 +901,6 @@
|
||||
"max_content_length": "Maximum Content Length",
|
||||
"use_reply": "Enable Reply Posts",
|
||||
"max_reply_depth": "Max Reply Depth",
|
||||
"max_reply_depth_hint": "Enter a value between 1 and 5",
|
||||
"use_comment": "Enable Comments",
|
||||
"comment_order": "Comment Sort Order",
|
||||
"min_comment_length": "Min Comment Length",
|
||||
@@ -974,7 +972,6 @@
|
||||
"min_content_length": "Set the minimum character count for post content",
|
||||
"max_content_length": "Set the maximum character count for post content",
|
||||
"use_reply": "Must be ON to apply reply post feature to new boards.",
|
||||
"max_reply_depth": "Set the maximum nesting depth for replies (1-5)",
|
||||
"blocked_keywords": "Posts or comments containing registered keywords will be blocked",
|
||||
"use_comment": "Must be ON to apply comment feature to new boards. Comment detail settings below will also apply.",
|
||||
"comment_order": "Set the default sort direction for comments",
|
||||
|
||||
@@ -173,12 +173,11 @@
|
||||
"html_preview_warning": "HTML Rendering Warning",
|
||||
"no_content_preview": "Preview will appear when you enter content",
|
||||
"attachments": "Attachments",
|
||||
"attachments_desc": "Max {{maxFiles}} files, {{maxSize}}MB each",
|
||||
"attached_files": "Attached Files",
|
||||
"confirm_remove_attachment_title": "Delete Attachment",
|
||||
"confirm_remove_attachment_message": "The file will be deleted immediately. This action cannot be undone regardless of saving the post.",
|
||||
"attachments_limit": "Max 5 files, 10MB each (jpg, jpeg, png, gif, pdf, zip)",
|
||||
"drop_files_or_click": "Click to upload or drag and drop",
|
||||
"max_10mb": "Max 10MB",
|
||||
"file_upload_component_needed": "FileUpload component needed",
|
||||
"uploading": "Uploading...",
|
||||
"reply_to": "Reply to the following post",
|
||||
|
||||
@@ -78,7 +78,6 @@
|
||||
"max_content_length": "Maximum Content Length",
|
||||
"use_reply": "Enable Reply Posts",
|
||||
"max_reply_depth": "Max Reply Depth",
|
||||
"max_reply_depth_hint": "Enter a value between 1 and 5",
|
||||
"use_comment": "Enable Comments",
|
||||
"comment_order": "Comment Sort Order",
|
||||
"min_comment_length": "Min Comment Length",
|
||||
|
||||
@@ -717,12 +717,11 @@
|
||||
"html_preview_warning": "HTML 렌더링 주의",
|
||||
"no_content_preview": "내용을 입력하면 미리보기가 표시됩니다",
|
||||
"attachments": "첨부파일",
|
||||
"attachments_desc": "최대 {{maxFiles}}개, 각 {{maxSize}}MB 이하",
|
||||
"attached_files": "첨부된 파일",
|
||||
"confirm_remove_attachment_title": "첨부파일 삭제",
|
||||
"confirm_remove_attachment_message": "파일이 즉시 삭제됩니다. 게시글 저장과 관계없이 되돌릴 수 없습니다.",
|
||||
"attachments_limit": "최대 5개 파일, 각 10MB 이하 (jpg, jpeg, png, gif, pdf, zip)",
|
||||
"drop_files_or_click": "클릭하여 업로드 또는 드래그 앤 드롭",
|
||||
"max_10mb": "최대 10MB",
|
||||
"file_upload_component_needed": "FileUpload 컴포넌트 개발 필요",
|
||||
"uploading": "업로드 중...",
|
||||
"reply_to": "다음 글에 대한 답변입니다",
|
||||
@@ -902,7 +901,6 @@
|
||||
"max_content_length": "최대 게시글 글자 수",
|
||||
"use_reply": "답변글 사용",
|
||||
"max_reply_depth": "답변글 최대 깊이",
|
||||
"max_reply_depth_hint": "1~5 사이의 값을 입력하세요",
|
||||
"use_comment": "댓글 사용",
|
||||
"comment_order": "댓글 정렬 순서",
|
||||
"min_comment_length": "최소 댓글 글자 수",
|
||||
@@ -974,7 +972,6 @@
|
||||
"min_content_length": "게시글 본문의 최소 글자 수를 설정합니다",
|
||||
"max_content_length": "게시글 본문의 최대 글자 수를 설정합니다",
|
||||
"use_reply": "ON으로 설정해야 새 게시판에 답변글 기능이 적용됩니다.",
|
||||
"max_reply_depth": "답변글의 최대 중첩 깊이를 설정합니다 (1~5)",
|
||||
"blocked_keywords": "등록된 키워드가 포함된 게시글/댓글은 작성이 차단됩니다",
|
||||
"use_comment": "ON으로 설정해야 새 게시판에 댓글 기능이 적용됩니다. 아래 댓글 세부 설정도 함께 적용됩니다.",
|
||||
"comment_order": "댓글의 기본 정렬 방향을 설정합니다",
|
||||
|
||||
@@ -174,12 +174,11 @@
|
||||
"html_preview_warning": "HTML 렌더링 주의",
|
||||
"no_content_preview": "내용을 입력하면 미리보기가 표시됩니다",
|
||||
"attachments": "첨부파일",
|
||||
"attachments_desc": "최대 {{maxFiles}}개, 각 {{maxSize}}MB 이하",
|
||||
"attached_files": "첨부된 파일",
|
||||
"confirm_remove_attachment_title": "첨부파일 삭제",
|
||||
"confirm_remove_attachment_message": "파일이 즉시 삭제됩니다. 게시글 저장과 관계없이 되돌릴 수 없습니다.",
|
||||
"attachments_limit": "최대 5개 파일, 각 10MB 이하 (jpg, jpeg, png, gif, pdf, zip)",
|
||||
"drop_files_or_click": "클릭하여 업로드 또는 드래그 앤 드롭",
|
||||
"max_10mb": "최대 10MB",
|
||||
"file_upload_component_needed": "FileUpload 컴포넌트 개발 필요",
|
||||
"uploading": "업로드 중...",
|
||||
"reply_to": "다음 글에 대한 답변입니다",
|
||||
|
||||
@@ -78,7 +78,6 @@
|
||||
"max_content_length": "최대 게시글 글자 수",
|
||||
"use_reply": "답변글 사용",
|
||||
"max_reply_depth": "답변글 최대 깊이",
|
||||
"max_reply_depth_hint": "1~5 사이의 값을 입력하세요",
|
||||
"use_comment": "댓글 사용",
|
||||
"comment_order": "댓글 정렬 순서",
|
||||
"min_comment_length": "최소 댓글 글자 수",
|
||||
|
||||
+9
@@ -47,6 +47,15 @@
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"children": [
|
||||
{
|
||||
"id": "attachments_desc",
|
||||
"type": "basic",
|
||||
"name": "P",
|
||||
"props": {
|
||||
"className": "text-xs text-gray-500 dark:text-gray-400 mb-2"
|
||||
},
|
||||
"text": "$t:sirsoft-board.admin.posts.form.attachments_desc|maxFiles={{form_meta?.data?.board?.max_file_count ?? 5}}|maxSize={{form_meta?.data?.board?.max_file_size ?? 10}}"
|
||||
},
|
||||
{
|
||||
"type": "composite",
|
||||
"name": "FileUploader",
|
||||
|
||||
@@ -803,6 +803,16 @@ class PostRepository implements PostRepositoryInterface
|
||||
$noticeQuery->with($relations);
|
||||
}
|
||||
|
||||
// 공지는 운영자가 등록하는 데이터라 통상 소수지만, 개수를 막는 장치가 없으면
|
||||
// 잘못 늘어난 게시판에서 한 페이지를 여는 것만으로 전량이 메모리에 올라온다.
|
||||
// `created_at desc` 정렬이므로 상한에 걸릴 때 최신 공지가 우선 보존된다 —
|
||||
// 잘린 공지는 후속 페이지에 노출될 경로가 없어 이 순서가 유일한 안전판이다.
|
||||
// 조정은 코어 필터 훅 `core.pagination.filter_result_cap` (context: board.notices).
|
||||
$noticeCap = PaginationLimits::resultCap('board.notices');
|
||||
if ($noticeCap !== null) {
|
||||
$noticeQuery->limit($noticeCap);
|
||||
}
|
||||
|
||||
$notices = $noticeQuery->get($columns);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Sirsoft\Board\Tests\Feature;
|
||||
|
||||
use App\Extension\HookManager;
|
||||
use App\Support\Query\PaginationLimits;
|
||||
use Modules\Sirsoft\Board\Models\Post;
|
||||
use Modules\Sirsoft\Board\Repositories\PostRepository;
|
||||
use Modules\Sirsoft\Board\Tests\BoardTestCase;
|
||||
|
||||
/**
|
||||
* 첫 페이지 공지 조회의 안전상한 회귀 테스트 (공개 #74)
|
||||
*
|
||||
* 목록 1페이지는 그 게시판의 공지 전량을 상한 없이 `get()` 으로 읽어 왔다. 공지는
|
||||
* 운영자가 등록하는 데이터라 통상 소수지만, 개수를 제한하는 장치가 어디에도 없어
|
||||
* 잘못 늘어난 게시판에서는 한 페이지를 여는 것만으로 그 전량이 메모리에 올라온다.
|
||||
*
|
||||
* 상한은 코어 `PaginationLimits::resultCap('board.notices')` 가 해석하며 확장은
|
||||
* `core.pagination.filter_result_cap` 필터 훅으로 조정한다. 상한을 리터럴로 다시
|
||||
* 적으면 코어 설정과 어긋나므로 여기서도 훅으로만 검증한다.
|
||||
*
|
||||
* 5단계 병합 구조(공지 → 원글 → 답글 → 정렬 → 병합)는 손대지 않는다 — 이 테스트는
|
||||
* 상한 부가가 일반 게시글 목록과 총 건수에 영향을 주지 않는 것까지 함께 고정한다.
|
||||
*
|
||||
* @scenario case=notice_result_cap
|
||||
*
|
||||
* @effects notice_query_respects_result_cap,
|
||||
* notice_cap_keeps_newest_first,
|
||||
* notice_cap_does_not_affect_normal_posts,
|
||||
* other_contexts_keep_their_own_cap
|
||||
*/
|
||||
class NoticeResultCapTest extends BoardTestCase
|
||||
{
|
||||
/** 공지 상한 컨텍스트 키 */
|
||||
private const CONTEXT = 'board.notices';
|
||||
|
||||
/** 훅 해제를 위해 등록한 콜백 */
|
||||
private $capFilter = null;
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
if ($this->capFilter !== null) {
|
||||
HookManager::removeFilter('core.pagination.filter_result_cap', $this->capFilter);
|
||||
$this->capFilter = null;
|
||||
}
|
||||
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
/**
|
||||
* 공지 컨텍스트에만 적용되는 상한 필터를 등록합니다.
|
||||
*
|
||||
* 다른 컨텍스트(예: 답글 트리)는 원값을 그대로 통과시켜, 상한이 컨텍스트별로
|
||||
* 분리되어 해석되는지도 함께 확인한다.
|
||||
*
|
||||
* @param int $cap 공지 컨텍스트에 적용할 상한
|
||||
*/
|
||||
private function capNoticesAt(int $cap): void
|
||||
{
|
||||
$this->capFilter = function ($value, $context = null) use ($cap) {
|
||||
return $context === self::CONTEXT ? $cap : $value;
|
||||
};
|
||||
|
||||
HookManager::addFilter('core.pagination.filter_result_cap', $this->capFilter, priority: 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* 게시글을 생성합니다.
|
||||
*
|
||||
* @param string $title 제목
|
||||
* @param bool $isNotice 공지 여부
|
||||
* @param int $minutesAgo 작성 시각 (분 단위 과거)
|
||||
*/
|
||||
private function makePost(string $title, bool $isNotice, int $minutesAgo): Post
|
||||
{
|
||||
$post = Post::create([
|
||||
'board_id' => $this->board->id,
|
||||
'user_id' => null,
|
||||
'parent_id' => null,
|
||||
'title' => $title,
|
||||
'content' => $title.' 본문',
|
||||
'author_name' => '작성자',
|
||||
'is_notice' => $isNotice,
|
||||
'status' => 'published',
|
||||
'depth' => 0,
|
||||
'ip_address' => '127.0.0.1',
|
||||
]);
|
||||
|
||||
// created_at 은 $fillable 이 아니라 create() 로는 들어가지 않는다.
|
||||
// 전부 같은 초로 저장되면 정렬 축이 사라져 "최신 우선 보존" 을 검증할 수 없다.
|
||||
$post->forceFill([
|
||||
'created_at' => now()->subMinutes($minutesAgo),
|
||||
'updated_at' => now()->subMinutes($minutesAgo),
|
||||
])->saveQuietly();
|
||||
|
||||
return $post;
|
||||
}
|
||||
|
||||
public function test_공지_조회가_설정된_안전상한을_넘지_않는다(): void
|
||||
{
|
||||
// 공지 5건 (오래된 것부터 1..5 — 5번이 가장 최신)
|
||||
for ($i = 1; $i <= 5; $i++) {
|
||||
$this->makePost("공지 {$i}", true, 100 - $i);
|
||||
}
|
||||
|
||||
$this->capNoticesAt(3);
|
||||
|
||||
$result = app(PostRepository::class)->paginate($this->board->slug, ['page' => 1], 15, false, $this->board);
|
||||
$notices = collect($result->items())->filter(fn ($p) => (bool) $p->is_notice);
|
||||
|
||||
$this->assertCount(3, $notices, '공지 상한이 조회에 반영되지 않았다');
|
||||
}
|
||||
|
||||
public function test_상한에_걸리면_최신_공지가_남는다(): void
|
||||
{
|
||||
for ($i = 1; $i <= 5; $i++) {
|
||||
$this->makePost("공지 {$i}", true, 100 - $i);
|
||||
}
|
||||
|
||||
$this->capNoticesAt(2);
|
||||
|
||||
$result = app(PostRepository::class)->paginate($this->board->slug, ['page' => 1], 15, false, $this->board);
|
||||
$titles = collect($result->items())
|
||||
->filter(fn ($p) => (bool) $p->is_notice)
|
||||
->pluck('title')
|
||||
->values()
|
||||
->all();
|
||||
|
||||
// created_at desc 정렬이므로 최신 2건(공지 5, 공지 4)이 남는다.
|
||||
// 잘린 공지는 후속 페이지에 노출될 경로가 없으므로 최신 우선 보존이 유일한 안전판이다.
|
||||
$this->assertSame(['공지 5', '공지 4'], $titles);
|
||||
}
|
||||
|
||||
public function test_공지_상한이_일반_게시글_목록과_총건수를_바꾸지_않는다(): void
|
||||
{
|
||||
for ($i = 1; $i <= 5; $i++) {
|
||||
$this->makePost("공지 {$i}", true, 100 - $i);
|
||||
}
|
||||
for ($i = 1; $i <= 4; $i++) {
|
||||
$this->makePost("일반글 {$i}", false, 50 - $i);
|
||||
}
|
||||
|
||||
$repository = app(PostRepository::class);
|
||||
|
||||
$uncapped = $repository->paginate($this->board->slug, ['page' => 1], 15, false, $this->board);
|
||||
$normalBefore = collect($uncapped->items())->reject(fn ($p) => (bool) $p->is_notice)->pluck('title')->values()->all();
|
||||
$totalBefore = $repository->countNormalPosts($this->board->slug);
|
||||
|
||||
$this->capNoticesAt(2);
|
||||
|
||||
$capped = $repository->paginate($this->board->slug, ['page' => 1], 15, false, $this->board);
|
||||
$normalAfter = collect($capped->items())->reject(fn ($p) => (bool) $p->is_notice)->pluck('title')->values()->all();
|
||||
$totalAfter = $repository->countNormalPosts($this->board->slug);
|
||||
|
||||
$this->assertSame($normalBefore, $normalAfter, '공지 상한이 일반 게시글 목록을 바꿨다');
|
||||
$this->assertCount(4, $normalAfter);
|
||||
|
||||
// 화면이 보여주는 총 건수는 이 목록과 별개 술어(countNormalPosts)로 세어진다.
|
||||
// 공지 상한이 그 경로까지 새면 페이저 끝이 줄어 뒤쪽 게시글에 도달할 수 없게 된다.
|
||||
// 잘린 값과 정확한 값을 구분해야 하므로 건수뿐 아니라 정확도까지 비교한다.
|
||||
$this->assertSame($totalBefore->total(), $totalAfter->total(), '공지 상한이 목록 총 건수를 바꿨다');
|
||||
$this->assertSame($totalBefore->totalRelation(), $totalAfter->totalRelation(), '공지 상한이 총 건수의 정확도를 바꿨다');
|
||||
|
||||
// 다음 페이지 존재 여부도 공지 상한과 무관해야 한다 (per_page + 1 실측 경로).
|
||||
$this->assertSame($uncapped->hasMorePages(), $capped->hasMorePages(), '공지 상한이 페이지 이동 범위를 바꿨다');
|
||||
}
|
||||
|
||||
public function test_다른_컨텍스트는_자기_상한을_유지한다(): void
|
||||
{
|
||||
$this->capNoticesAt(2);
|
||||
|
||||
$this->assertSame(2, PaginationLimits::resultCap(self::CONTEXT));
|
||||
$this->assertNotSame(2, PaginationLimits::resultCap('board.reply_tree'));
|
||||
}
|
||||
}
|
||||
+151
@@ -0,0 +1,151 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Sirsoft\Board\Tests\Feature\Upgrade;
|
||||
|
||||
require_once __DIR__.'/../../ModuleTestCase.php';
|
||||
|
||||
use App\Extension\UpgradeContext;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Modules\Sirsoft\Board\Tests\BoardTestCase;
|
||||
use Modules\Sirsoft\Board\Upgrades\Upgrade_1_0_3;
|
||||
|
||||
/**
|
||||
* 1.0.3 게시글 목록 조회수 정렬 색인 업그레이드 스텝 테스트
|
||||
*
|
||||
* 신규 설치는 마이그레이션(`2026_08_06_000002`)이 색인을 만들지만, 이미 설치를 마친
|
||||
* 사이트에도 확실히 도달하도록 업그레이드 데이터 스텝을 함께 둔다(같은 1.0.3 의
|
||||
* `04_AddTiebreakToPostListIndex` · `05_AddReportListSortIndex` 와 동일 규약).
|
||||
*
|
||||
* 검증 목적:
|
||||
* - 색인이 없는 상태(기설치본 모사)에서 스텝이 색인을 만든다
|
||||
* - 재실행해도 예외 없이 결과가 같다 (멱등)
|
||||
* - 인기글 조회가 쓰는 기존 색인을 건드리지 않는다
|
||||
*
|
||||
* @group board
|
||||
* @group upgrade
|
||||
*/
|
||||
class PostListViewCountIndexUpgradeTest extends BoardTestCase
|
||||
{
|
||||
private const TABLE = 'board_posts';
|
||||
|
||||
private const INDEX_NAME = 'idx_board_posts_list_views';
|
||||
|
||||
/** 인기글 조회가 이름으로 의존하는 기존 색인 — 보존되어야 한다. */
|
||||
private const LEGACY_INDEX_NAME = 'idx_board_posts_board_view_count';
|
||||
|
||||
protected function getTestBoardSlug(): string
|
||||
{
|
||||
return 'post-list-view-count-index';
|
||||
}
|
||||
|
||||
protected function getDefaultBoardAttributes(string $slug): array
|
||||
{
|
||||
return [
|
||||
'slug' => $slug,
|
||||
'name' => ['ko' => '조회수 색인 게시판', 'en' => 'View Count Index Board'],
|
||||
'is_active' => true,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 색인이 없는 기설치본에서 업그레이드 스텝이 색인을 만든다
|
||||
*
|
||||
* @scenario case=post_list_view_count_index_upgrade
|
||||
*
|
||||
* @effects view_count_list_index_created_on_existing_site
|
||||
*/
|
||||
public function test_색인이_없으면_업그레이드_스텝이_생성한다(): void
|
||||
{
|
||||
$this->dropIndexIfExists();
|
||||
$this->assertFalse($this->hasIndex(self::INDEX_NAME), '사전 조건: 색인이 없어야 한다');
|
||||
|
||||
$this->runUpgrade();
|
||||
|
||||
$this->assertTrue(
|
||||
$this->hasIndex(self::INDEX_NAME),
|
||||
'업그레이드 스텝이 조회수 정렬 색인을 만들어야 한다'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 재실행해도 예외 없이 결과가 같다 (멱등)
|
||||
*
|
||||
* @scenario case=post_list_view_count_index_upgrade
|
||||
*
|
||||
* @effects view_count_list_index_upgrade_is_idempotent
|
||||
*/
|
||||
public function test_이미_있으면_재실행해도_결과가_같다(): void
|
||||
{
|
||||
$this->dropIndexIfExists();
|
||||
$this->runUpgrade();
|
||||
$this->assertTrue($this->hasIndex(self::INDEX_NAME));
|
||||
|
||||
// 색인이 이미 있는 상태에서 한 번 더 — 중복 생성 예외가 나면 실패한다.
|
||||
$this->runUpgrade();
|
||||
|
||||
$this->assertTrue(
|
||||
$this->hasIndex(self::INDEX_NAME),
|
||||
'재실행 후에도 색인이 그대로 있어야 한다'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 인기글 조회가 쓰는 기존 색인을 건드리지 않는다
|
||||
*
|
||||
* @scenario case=post_list_view_count_index_upgrade
|
||||
*
|
||||
* @effects legacy_popular_posts_index_preserved
|
||||
*/
|
||||
public function test_기존_인기글_색인을_보존한다(): void
|
||||
{
|
||||
$legacyBefore = $this->hasIndex(self::LEGACY_INDEX_NAME);
|
||||
|
||||
$this->dropIndexIfExists();
|
||||
$this->runUpgrade();
|
||||
|
||||
$this->assertSame(
|
||||
$legacyBefore,
|
||||
$this->hasIndex(self::LEGACY_INDEX_NAME),
|
||||
'조회수 목록 색인 추가가 인기글 색인의 존재 여부를 바꾸면 안 된다'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 1.0.3 업그레이드 스텝을 실행합니다.
|
||||
*/
|
||||
private function runUpgrade(): void
|
||||
{
|
||||
$context = new UpgradeContext('1.0.2', '1.0.3', '1.0.3', 'extension-upgrade');
|
||||
|
||||
(new Upgrade_1_0_3)->run($context);
|
||||
}
|
||||
|
||||
/**
|
||||
* 대상 색인이 존재하면 제거합니다 (기설치본 모사).
|
||||
*/
|
||||
private function dropIndexIfExists(): void
|
||||
{
|
||||
if (! $this->hasIndex(self::INDEX_NAME)) {
|
||||
return;
|
||||
}
|
||||
|
||||
Schema::table(self::TABLE, function ($table) {
|
||||
$table->dropIndex(self::INDEX_NAME);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 색인 존재 여부를 조회합니다.
|
||||
*
|
||||
* @param string $name 색인 이름
|
||||
* @return bool 존재 여부
|
||||
*/
|
||||
private function hasIndex(string $name): bool
|
||||
{
|
||||
if (! Schema::hasTable(self::TABLE)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return in_array($name, array_column(Schema::getIndexes(self::TABLE), 'name'), true);
|
||||
}
|
||||
}
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* 관리자 게시글 폼 — 첨부 안내가 게시판 설정값으로 렌더 (공개 #81 L-01).
|
||||
*
|
||||
* 관리자 게시글 폼의 첨부 영역에는 안내 문구가 없었고, 대신 사장된 lang 키
|
||||
* ("최대 5개 파일, 각 10MB 이하")만 남아 있었다. 그 문구는 어디에도 렌더되지 않는데
|
||||
* 설령 렌더되었어도 게시판마다 다른 실제 제한과 무관한 고정 숫자였다.
|
||||
*
|
||||
* 안내는 `form_meta.data.board.max_file_count/max_file_size` 를 파라미터로 받아야 한다.
|
||||
* 단위(Vitest admin-board-post-form-attachments-desc) 는 안내 노드만 떼어 렌더하므로,
|
||||
* 실제 폼에서 form_meta 데이터소스가 도착한 뒤의 치환 결과는 브라우저로만 확인 가능하다
|
||||
* (위지윅 발행 회귀 #238 교훈).
|
||||
*
|
||||
* @scenario board-post-form-attachments-desc
|
||||
* @effects admin_attachment_hint_renders_board_limits,
|
||||
* admin_attachment_hint_placeholders_not_raw
|
||||
*
|
||||
* 활성화 절차: PlaywrightIssueToken 발급이 가능한 환경에서 test.describe.skip → test.describe.
|
||||
*/
|
||||
import { test, expect, authenticatePage } from '../../fixtures/board-auth';
|
||||
|
||||
/** 첨부 기능이 켜진 게시판의 관리자 글쓰기 화면 */
|
||||
const WRITE_URL = '/admin/board/free/write';
|
||||
|
||||
test.describe.skip('관리자 게시글 폼 — 첨부 안내 (#81 L-01)', () => {
|
||||
test('첨부 안내가 게시판 설정의 개수·용량으로 렌더된다', async ({ page, settingsToken }) => {
|
||||
await authenticatePage(page, settingsToken);
|
||||
await page.goto(WRITE_URL);
|
||||
await page.waitForLoadState('domcontentloaded', { timeout: 30_000 });
|
||||
|
||||
// form_meta 도착 후 안내가 채워질 때까지 대기 — 숫자는 게시판 설정에서 온다
|
||||
const hint = page.getByText(/^최대 \d+개, 각 \d+MB 이하$/);
|
||||
await expect(hint).toBeVisible({ timeout: 10_000 });
|
||||
});
|
||||
|
||||
test('raw 플레이스홀더({{maxFiles}}/{{maxSize}})가 노출되지 않는다', async ({
|
||||
page,
|
||||
settingsToken,
|
||||
}) => {
|
||||
await authenticatePage(page, settingsToken);
|
||||
await page.goto(WRITE_URL);
|
||||
await page.waitForLoadState('domcontentloaded', { timeout: 30_000 });
|
||||
|
||||
await expect(page.getByText(/^최대 \d+개, 각 \d+MB 이하$/)).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
await expect(page.getByText('{{maxFiles}}', { exact: false })).toHaveCount(0);
|
||||
await expect(page.getByText('{{maxSize}}', { exact: false })).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('사장된 고정 숫자 안내가 다시 나타나지 않는다', async ({ page, settingsToken }) => {
|
||||
await authenticatePage(page, settingsToken);
|
||||
await page.goto(WRITE_URL);
|
||||
await page.waitForLoadState('domcontentloaded', { timeout: 30_000 });
|
||||
|
||||
await expect(page.getByText(/^최대 \d+개, 각 \d+MB 이하$/)).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// 종전 사장 키의 고정 문구 (jpg, jpeg, ... 확장자 나열 포함) 는 제거되었다
|
||||
await expect(
|
||||
page.getByText('최대 5개 파일, 각 10MB 이하', { exact: false }),
|
||||
).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
+74
@@ -0,0 +1,74 @@
|
||||
<?php
|
||||
|
||||
namespace App\Upgrades\Data\Ext\Modules\SirsoftBoard\V1_0_3\Migrations;
|
||||
|
||||
use App\Extension\Upgrade\DataMigration;
|
||||
use App\Extension\UpgradeContext;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* 게시글 목록의 조회수 정렬 색인을 추가합니다.
|
||||
*
|
||||
* 조회수 정렬은 화면에서 실제로 도달 가능한 경로인데(게시판 설정의 기본 정렬과 목록
|
||||
* URL 둘 다 있습니다), 목록 술어를 등치 사슬로 덮으면서 `view_count` 로 끝나는 색인이
|
||||
* 없어 뒤쪽 페이지가 테이블 전체를 훑고 정렬했습니다.
|
||||
*
|
||||
* 신규 설치는 마이그레이션이 처리하지만 기존 사이트에는 반영되지 않으므로 업그레이드
|
||||
* 시점에 같은 색인을 추가합니다.
|
||||
*
|
||||
* 게시글이 많이 쌓인 사이트에서는 수 분 걸릴 수 있고 그동안 글쓰기가 대기합니다.
|
||||
*
|
||||
* 기존 `idx_board_posts_board_view_count` 는 건드리지 않습니다 — 인기글 조회가 쓰는
|
||||
* 색인이며 이 목록 색인이 대신하지 못합니다.
|
||||
*
|
||||
* idempotent: 이미 존재하는 색인은 건너뜁니다. V-1 안전: Facades\Schema 만 사용합니다.
|
||||
*/
|
||||
class AddPostListViewCountIndex implements DataMigration
|
||||
{
|
||||
private const TABLE = 'board_posts';
|
||||
|
||||
private const INDEX_NAME = 'idx_board_posts_list_views';
|
||||
|
||||
/** @var array<int, string> */
|
||||
private const INDEX_COLUMNS = ['board_id', 'is_notice', 'parent_id', 'deleted_at', 'view_count', 'id'];
|
||||
|
||||
/**
|
||||
* 마이그레이션 식별자를 반환합니다.
|
||||
*
|
||||
* @return string 마이그레이션 이름
|
||||
*/
|
||||
public function name(): string
|
||||
{
|
||||
return 'AddPostListViewCountIndex';
|
||||
}
|
||||
|
||||
/**
|
||||
* 게시글 목록 조회수 정렬 색인을 추가합니다.
|
||||
*
|
||||
* @param UpgradeContext $context 업그레이드 컨텍스트
|
||||
*/
|
||||
public function run(UpgradeContext $context): void
|
||||
{
|
||||
if (! Schema::hasTable(self::TABLE)) {
|
||||
$context->logger->info('[board:1.0.3] 게시글 테이블 부재 — 조회수 정렬 색인 추가 스킵');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$existing = array_column(Schema::getIndexes(self::TABLE), 'name');
|
||||
|
||||
if (in_array(self::INDEX_NAME, $existing, true)) {
|
||||
$context->logger->info('[board:1.0.3] 이미 존재하는 색인 — 스킵: '.self::INDEX_NAME);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$context->logger->info('[board:1.0.3] 게시글 목록 조회수 정렬 색인 추가 시작 (게시글이 많으면 수 분 걸릴 수 있고 그동안 글쓰기가 대기합니다)');
|
||||
|
||||
Schema::table(self::TABLE, function ($table) {
|
||||
$table->index(self::INDEX_COLUMNS, self::INDEX_NAME);
|
||||
});
|
||||
|
||||
$context->logger->info('[board:1.0.3] 게시글 목록 조회수 정렬 색인 추가 완료: '.self::INDEX_NAME);
|
||||
}
|
||||
}
|
||||
@@ -98,6 +98,9 @@
|
||||
|
||||
### Fixed
|
||||
|
||||
- 쇼핑몰 주소를 기본값이 아닌 값으로 바꾸거나 주소 없이 운영하는 상점에서, 서버가 만들어 보내는 상점 주소가 기본 주소로 잘못 만들어지던 문제를 수정했습니다. 주문 완료 후 이동, 비회원 주문 조회 안내, 알림 메일의 주문 조회 주소, 통합검색·상품문의 목록의 상품 링크, 검색엔진용 주소(사이트맵·봇 화면 캐시)가 모두 해당됐습니다. 링크가 만들어지기는 해서 오류로 드러나지 않고 없는 페이지로만 이어졌습니다. 기본 주소를 쓰는 상점은 이전과 동일합니다. (#85 @hwaryeon1234 님께서 제보해주셨습니다.)
|
||||
- 쇼핑몰 주소를 바꾸거나 주소 없이 운영하는 상점에서 화면 편집기의 상품·장바구니·주문서 등 상점 화면을 편집할 때 상태 미리보기 전환(품절/재고 있음 등)이 표시되지 않던 문제를 수정했습니다. 편집기가 기본 주소인 상점만 알아보던 탓이며, 이제 어떤 주소로 바꾸어도 — 주소 없이 운영하는 상점까지 — 동일하게 표시됩니다. (#85 @hwaryeon1234 님께서 제보해주셨습니다.)
|
||||
- 상품 상세 화면에서만 상태 미리보기 전환(재고 있음/품절/판매중지/판매예정)이 어떤 쇼핑몰 주소에서도 표시되지 않던 문제를 수정했습니다. 편집기가 찾는 화면 주소와 실제 상품 상세 주소의 표기가 서로 달라 끝내 연결되지 않았습니다. (#85 @hwaryeon1234 님께서 제보해주셨습니다.)
|
||||
- "최근 본 상품" 과 찜 목록의 상품 카드가 리뷰가 달린 상품도 별점 0 개 · 리뷰 0건으로 표시하던 문제를 수정했습니다. 두 조회만 리뷰를 세지 않아 값이 비어 있었고, 빈 값이 0 으로 표기되어 실제 평점과 다르게 보였습니다. 이제 다른 상품 목록과 동일하게 실제 평점·리뷰 수가 표시됩니다.
|
||||
- 리뷰를 세지 않는 조회 경로(관리자 상품 목록 등)에서는 평점·리뷰 수 항목을 아예 싣지 않도록 바꿨습니다. 종전에는 세지 않았을 뿐인데 0 이 실려 "리뷰 없음" 과 구분되지 않았습니다. 리뷰가 실제로 0건이면 종전대로 0 으로 표시됩니다.
|
||||
- 모바일·태블릿 화면에서 쿠폰 목록의 카드 보기가 내용 없이 나오던 문제를 수정했습니다. 목록의 각 쿠폰 정보가 카드에 전달되지 않아, 쿠폰이 있어도 빈 카드만 표시됐습니다.
|
||||
|
||||
@@ -1382,18 +1382,18 @@ _목록 응답: `data.data[]` 배열 항목의 필드._
|
||||
| 필드 | 타입 | 실측 예시값 | 용도/설명 |
|
||||
| --- | --- | --- | --- |
|
||||
| id | integer | `27789` | 기본 키 (내부 식별자) |
|
||||
| log_type | string | `admin` | <!-- TODO: 설명 --> |
|
||||
| log_type | string | `admin` | 로그 분류 (`ActivityLogType` Enum 값 — 관리자/사용자/시스템 등 활동 주체 구분) |
|
||||
| log_type_label | string | `관리자` | `log_type` 값의 사람이 읽는 라벨 (현지화/Enum 파생) |
|
||||
| loggable_type | string | `Modules\Sirsoft\Ecommerce\Models\Orde…` | <!-- TODO: 설명 --> |
|
||||
| loggable_type_display | string | `OrderOption` | <!-- TODO: 설명 --> |
|
||||
| loggable_type | string | `Modules\Sirsoft\Ecommerce\Models\Orde…` | 로그 대상 모델의 FQCN (다형 관계 타입 — 표시용 짧은 이름은 `loggable_type_display`) |
|
||||
| loggable_type_display | string | `OrderOption` | `loggable_type` 의 표시용 짧은 이름 (네임스페이스를 제외한 클래스명) |
|
||||
| loggable_id | integer | `1264` | loggable 식별자 (연관 리소스 참조) |
|
||||
| action | string | `order_option.partial_cancel` | <!-- TODO: 설명 --> |
|
||||
| action | string | `order_option.partial_cancel` | 수행된 활동의 식별 키 (`{대상}.{행위}` 형식 — 라벨은 `action_label`) |
|
||||
| action_label | string | `부분 취소` | `action` 값의 사람이 읽는 라벨 (현지화/Enum 파생) |
|
||||
| localized_description | string | `주문 옵션 부분 취소 (옵션 ID: 1264)` | `description` 의 현재 로케일 해석 값 (다국어 필드를 표시용 문자열로 해석) |
|
||||
| description_key | string | `sirsoft-ecommerce::activity_log.descr…` | <!-- TODO: 설명 --> |
|
||||
| properties | object | `{"order_id":1316,"product_name":{"ko":"신제품 출시 예정 #21","en…` | <!-- TODO: 설명 --> |
|
||||
| changes | null | `null` | <!-- TODO: 설명 --> |
|
||||
| bulk_changes | null | `null` | <!-- TODO: 설명 --> |
|
||||
| description_key | string | `sirsoft-ecommerce::activity_log.descr…` | 설명 문구의 다국어 키 (`localized_description` 은 이 키를 현재 로케일로 해석한 값) |
|
||||
| properties | object | `{"order_id":1316,"product_name":{"ko":"신제품 출시 예정 #21","en…` | 활동 시점의 부가 정보 (설명 문구의 치환 파라미터 및 참조 식별자 — 활동 종류마다 키가 다름) |
|
||||
| changes | null | `null` | 단건 수정의 변경 내역 `[{field, old, new}, …]`. 일괄 수정이거나 변경 추적 대상이 아니면 `null` |
|
||||
| bulk_changes | null | `null` | 일괄 수정의 변경 내역 `[{model_id, changes[]}, …]`. 단건 수정이면 `null` (`changes` 와 동시에 채워지지 않음) |
|
||||
| has_changes | boolean | `false` | changes 여부 |
|
||||
| actor_name | string | `시스템` | 행위를 수행한 주체(사용자/시스템)의 이름 |
|
||||
| user | object | `{"name":"시스템"}` | 대상 사용자 정보 객체 (uuid/name/email 등 — user 관계 파생) |
|
||||
@@ -2073,7 +2073,7 @@ _단건 응답: `data` 객체의 필드 (HTTP 201). 회원은 `order` 에 `Order
|
||||
| 필드 | 타입 | 실측 예시값 | 용도/설명 |
|
||||
| --- | --- | --- | --- |
|
||||
| order | object | `{"id":181,"order_number":"20260711-0210001234", …}` | 생성된 주문 (회원: `OrderResource` / 비회원: `GuestOrderResource`) |
|
||||
| redirect_url | string | `/shop/orders/20260711-0210001234/complete` | 주문완료 페이지 경로 (프론트가 이동할 URL) |
|
||||
| redirect_url | string | `/shop/orders/20260711-0210001234/complete` | 주문완료 페이지 경로 (프론트가 이동할 URL). 앞의 상점 경로는 상점 주소 설정을 반영한다 — `basic_info.route_path` 를 바꾸면 그 값이(`/store/orders/…`), `basic_info.no_route` 를 켜면 세그먼트 없이(`/orders/…`) 내려간다 |
|
||||
| requires_pg_payment | boolean | `true` | PG 결제창 호출이 필요한지 여부 (무통장·전액 마일리지 등 non-PG 는 `false`) |
|
||||
| pg_provider | string | `sirsoft-tosspayments` | PG 플러그인 식별자 (`requires_pg_payment=true` 일 때만 포함) |
|
||||
| pg_payment_handler | string | `sirsoft-tosspayments.requestPayment` | 프론트가 dispatch 할 결제 진입 핸들러 풀네임 (provider 가 선언한 경우에만 포함) |
|
||||
@@ -3221,7 +3221,7 @@ HTTP/1.1 200
|
||||
|
||||
| 상태코드 | 의미 | 발생 조건 |
|
||||
| --- | --- | --- |
|
||||
| 404 | Not Found | 주문이 없거나 접근 권한이 없는 경우 — 회원: 본인 주문 아님(`errors.redirect_to = /mypage/orders`), 비회원: `X-Guest-Order-Token` 부재·만료·위조(`errors.redirect_to = /shop/guest/orders`). 정보 노출 방지를 위해 모든 실패를 동일 404 로 처리 |
|
||||
| 404 | Not Found | 주문이 없거나 접근 권한이 없는 경우 — 회원: 본인 주문 아님(`errors.redirect_to = /mypage/orders`), 비회원: `X-Guest-Order-Token` 부재·만료·위조(`errors.redirect_to = /shop/guest/orders` — 상점 주소 설정 `basic_info.route_path`/`no_route` 반영). 정보 노출 방지를 위해 모든 실패를 동일 404 로 처리 |
|
||||
|
||||
<!-- @generated:end -->
|
||||
|
||||
|
||||
@@ -4729,13 +4729,13 @@
|
||||
}
|
||||
},
|
||||
"states": {
|
||||
"comment": "이커머스 도메인 변종. 사용자 shop/* 라우트(템플릿 sirsoft-basic 소속이나 코어 concat 으로 일원화)와 관리자 라우트(*/admin/ecommerce/*) 변종을 모두 포함한다. shop/* scope.match 는 편집기가 동적 path 를 'shop' 으로 평가한 selectedRoute.path 와 일치한다(Chrome MCP 실측).",
|
||||
"comment": "이커머스 도메인 변종. 사용자 상점 라우트(템플릿 sirsoft-basic 소속이나 코어 concat 으로 일원화)와 관리자 라우트(*/admin/ecommerce/*) 변종을 모두 포함한다. 상점 주소는 운영자 설정이라 편집기가 평가한 selectedRoute.path 의 첫 세그먼트가 사이트마다 다르다 — 기본 'shop', basic_info.route_path 로 바꾸면 그 값, basic_info.no_route 를 켜면 세그먼트가 아예 없다(`/products`). 그래서 scope.match 는 선택 세그먼트 토큰 '/*?'(0개 또는 1개, engine-v1.58.0)를 써 세 경우를 모두 매칭한다. '/*' 로 쓰면 no_route 상점에서만 매칭이 실패해 상태 토글이 조용히 사라진다. 파라미터 토큰(:id 등)은 리터럴로 대조되므로 routes.json 이 쓴 이름을 그대로 적어야 한다 — 이름이 다르면 세그먼트 토큰과 무관하게 세 축 모두에서 매칭되지 않는다.",
|
||||
"groups": [
|
||||
{
|
||||
"comment": "shop/show — product.data.sales_status 값(on_sale/sold_out/suspended/coming_soon)으로 판매 상태 배지·버튼을 if 분기한다(본문 line 388/406). 재고는 stock 으로 분기.",
|
||||
"comment": "shop/show — product.data.sales_status 값(on_sale/sold_out/suspended/coming_soon)으로 판매 상태 배지·버튼을 if 분기한다(본문 line 388/406). 재고는 stock 으로 분기. 파라미터 토큰은 routes.json 의 ':product_code' 를 그대로 쓴다 — ':id' 로 적으면 리터럴 대조라 어떤 상점 주소에서도 매칭되지 않는다.",
|
||||
"scope": {
|
||||
"kind": "route",
|
||||
"match": "/shop/products/:id"
|
||||
"match": "/*?/products/:product_code"
|
||||
},
|
||||
"items": [
|
||||
{
|
||||
@@ -7099,7 +7099,7 @@
|
||||
"comment": "shop/index — 목록 유무 + 접근 제한. _local.productAccessDenied=true 시 접근 제한 화면을 if 분기한다(본문 line 192).",
|
||||
"scope": {
|
||||
"kind": "route",
|
||||
"match": "/shop/products"
|
||||
"match": "/*?/products"
|
||||
},
|
||||
"items": [
|
||||
{
|
||||
@@ -7140,7 +7140,7 @@
|
||||
"comment": "shop/cart — 담긴 상품 유무 + 주문 처리 중(_local.isOrdering 시 오버레이 잠금, 본문 line 115).",
|
||||
"scope": {
|
||||
"kind": "route",
|
||||
"match": "/shop/cart"
|
||||
"match": "/*?/cart"
|
||||
},
|
||||
"items": [
|
||||
{
|
||||
@@ -7222,7 +7222,7 @@
|
||||
"comment": "shop/checkout — 정상/결제오류(_local.orderError·query.error, 본문 line 45/194)/구매불가(_local.unavailableModal, line 102)/입력검증실패 분기. 검증 실패는 주문자/배송지 입력칸별 인라인 에러를 _local.errors 의 점 포함 flat 키(예 'orderer.name')로 읽는다(_checkout_orderer.json line 99/101, _checkout_shipping.json) — formErrors 키를 대괄호 표기로 지정한다.",
|
||||
"scope": {
|
||||
"kind": "route",
|
||||
"match": "/shop/checkout"
|
||||
"match": "/*?/checkout"
|
||||
},
|
||||
"items": [
|
||||
{
|
||||
@@ -7291,7 +7291,7 @@
|
||||
"comment": "shop/order_complete — orderData.data.payment.payment_method 값(card/vbank/dbank)으로 결제수단별 안내를 if 분기한다(본문 line 52/95/138/229). card 는 base sampleData(=card)가 흐르므로 오버라이드 없음, vbank/dbank 는 byDataSourceId.orderData 통째 교체.",
|
||||
"scope": {
|
||||
"kind": "route",
|
||||
"match": "/shop/orders/:id/complete"
|
||||
"match": "/*?/orders/:id/complete"
|
||||
},
|
||||
"items": [
|
||||
{
|
||||
@@ -7747,7 +7747,7 @@
|
||||
"comment": "shop/reorder — _local.status 값(pending/success/error)으로 재주문 진행 화면을 if 분기한다(본문 line 72/99/225).",
|
||||
"scope": {
|
||||
"kind": "route",
|
||||
"match": "/shop/reorder/:id"
|
||||
"match": "/*?/reorder/:id"
|
||||
},
|
||||
"items": [
|
||||
{
|
||||
@@ -8394,7 +8394,7 @@
|
||||
"comment": "shop/guest_order_form — 비회원 주문 조회 폼. 본문이 _global.currentUser.uuid 유무로 양분된다(slots.content[0] 회원→마이페이지 리다이렉트 안내 / content[1] 비회원→조회 폼). 편집기 sampleGlobal 은 로그인 상태(currentUser 시드)라 비회원 조회 폼이 기본 미표시이므로, currentUser 를 null 로 패치한 비회원 상태를 기본으로 노출해 폼을 편집 가능하게 한다.",
|
||||
"scope": {
|
||||
"kind": "route",
|
||||
"match": "/shop/guest/orders"
|
||||
"match": "/*?/guest/orders"
|
||||
},
|
||||
"items": [
|
||||
{
|
||||
|
||||
+1
-1
@@ -28,7 +28,7 @@ const spec = JSON.parse(
|
||||
fs.readFileSync(path.join(REPO_ROOT, 'modules/_bundled/sirsoft-ecommerce/editor-spec.json'), 'utf-8'),
|
||||
);
|
||||
const checkoutGroup = spec.states.groups.find(
|
||||
(g: any) => g.scope?.kind === 'route' && g.scope?.match === '/shop/checkout',
|
||||
(g: any) => g.scope?.kind === 'route' && g.scope?.match === '/*?/checkout',
|
||||
);
|
||||
const normal = checkoutGroup?.items.find((i: any) => i.id === 'normal');
|
||||
|
||||
|
||||
+18
-3
@@ -8,7 +8,7 @@
|
||||
* 그룹이 없으면 비회원 조회 폼이 캔버스에 영영 미표시되어 편집 불가였다.
|
||||
*
|
||||
* 본 테스트는 develop 신규 화면에 대응해 추가한 상태 그룹의 계약을 가드한다:
|
||||
* - scope.match 가 정규화 라우트 path(`/shop/guest/orders`)와 일치
|
||||
* - scope.match 가 상점 주소 설정과 무관하게 정규화 라우트 path 와 일치 (공개 #85)
|
||||
* - 기본(guest) 상태가 `global.currentUser: null` 패치로 비회원 폼을 노출
|
||||
* - 회원(member) 상태가 `global.currentUser.uuid` 명시 시드로 마이페이지 안내 분기를 노출
|
||||
* - 두 상태 라벨이 `$t:` 친화 키
|
||||
@@ -25,6 +25,8 @@ import { describe, it, expect } from 'vitest';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
|
||||
import { matchStateItems } from '../../../../../../../resources/js/core/template-engine/layout-editor/utils/matchStateScope';
|
||||
|
||||
function findProjectRoot(startDir: string): string {
|
||||
let dir = startDir;
|
||||
while (dir !== path.dirname(dir)) {
|
||||
@@ -39,18 +41,31 @@ const spec = JSON.parse(
|
||||
fs.readFileSync(path.join(REPO_ROOT, 'modules/_bundled/sirsoft-ecommerce/editor-spec.json'), 'utf-8'),
|
||||
);
|
||||
const group = spec.states.groups.find(
|
||||
(g: any) => g.scope?.kind === 'route' && g.scope?.match === '/shop/guest/orders',
|
||||
(g: any) => g.scope?.kind === 'route' && g.scope?.match === '/*?/guest/orders',
|
||||
);
|
||||
const guest = group?.items.find((i: any) => i.id === 'guest');
|
||||
const member = group?.items.find((i: any) => i.id === 'member');
|
||||
|
||||
describe('guest_order_form 상태 그룹', () => {
|
||||
it('정규화 라우트 path(`/shop/guest/orders`)에 상태 그룹이 존재한다', () => {
|
||||
it('정규화 라우트 path 에 상태 그룹이 존재한다', () => {
|
||||
expect(group).toBeTruthy();
|
||||
// items 2개 이상이어야 편집기 캔버스에 상태 드롭다운(PageStateSwitcher)이 표시된다.
|
||||
expect(group.items.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
// 공개 #85 — 상점 주소는 운영자 설정이라 편집기가 평가한 라우트 path 의 첫 세그먼트가
|
||||
// 사이트마다 다르다. scope.match 를 `/shop/...` 리터럴로 두면 주소를 바꾼 상점에서
|
||||
// 상태 그룹이 매칭되지 않아 상태 토글이 조용히 사라진다(예외·경고 없음).
|
||||
// 실제 소비자(matchStateItems)로 판정해 정규식 의미까지 고정한다.
|
||||
it.each([
|
||||
['기본 상점 주소', '/shop/guest/orders'],
|
||||
['운영자가 바꾼 주소', '/store/guest/orders'],
|
||||
['주소 없이 운영하는 상점(no_route)', '/guest/orders'],
|
||||
])('%s 라우트에서 상태 그룹이 매칭된다', (_label, routePath) => {
|
||||
const items = matchStateItems(spec.states.groups, { kind: 'route', match: routePath });
|
||||
expect(items.map((i) => i.id)).toEqual(expect.arrayContaining(['guest', 'member']));
|
||||
});
|
||||
|
||||
it('기본(guest) 상태가 currentUser 를 null 로 패치해 비회원 조회 폼을 노출한다', () => {
|
||||
expect(guest?.default).toBe(true);
|
||||
// global.currentUser === null → `_global.currentUser?.uuid` undefined → content[1] 폼 활성.
|
||||
|
||||
+2
-2
@@ -238,13 +238,13 @@ describe('상품 도메인 편집기 샘플 — sirsoft-ecommerce admin', () =>
|
||||
});
|
||||
});
|
||||
|
||||
describe('/shop/products/:id 상태 override — 통째 교체이므로 충실 shape 유지', () => {
|
||||
describe('상품 상세 상태 override — 통째 교체이므로 충실 shape 유지', () => {
|
||||
// Chrome MCP 실측 회귀(2026-06-03): 비-기본 상태 override 가 빈약 stub(name:"샘플 상품 1",
|
||||
// price, stock_status, images:[], options:[])이라 캔버스가 붕괴(277자)했다. override 는
|
||||
// base sampleData 를 "통째 교체"(pageStateSimulator.resolveSampleOverride)하므로
|
||||
// 누락 필드는 undefined → 렌더 붕괴. base 와 동일 충실 shape 를 유지해야 한다.
|
||||
const group = spec.states.groups.find(
|
||||
(g: any) => g.scope?.kind === 'route' && g.scope?.match === '/shop/products/:id',
|
||||
(g: any) => g.scope?.kind === 'route' && g.scope?.match === '/*?/products/:product_code',
|
||||
);
|
||||
const PRODUCT_EP = '/api/modules/sirsoft-ecommerce/products/*';
|
||||
const stateOf = (id: string) =>
|
||||
|
||||
+110
@@ -0,0 +1,110 @@
|
||||
// e2e:allow 편집기 상태 스코프 ↔ 라우트 토큰 정합만 검사하는 정적 계약 테스트 — 런타임 동작 무변경, 해당 화면 E2E 는 기존 spec 으로 커버됨
|
||||
/**
|
||||
* 상점 상태 그룹 scope ↔ routes.json 라우트 패리티 계약 테스트
|
||||
*
|
||||
* 편집기는 템플릿 routes.json 의 동적 path 표현식을 평가해 `selectedRoute.path` 를
|
||||
* 만들고, 그 값을 editor-spec 의 `scope.match` 와 대조해 상태 토글을 띄운다. 따라서
|
||||
* scope 는 **routes.json 이 실제로 만들어 내는 path 문자열**과 일치해야 한다.
|
||||
*
|
||||
* 이 테스트가 막는 회귀는 두 축이다:
|
||||
*
|
||||
* 1. 세그먼트 축 — 상점 주소는 운영자 설정이라 첫 세그먼트가 사이트마다 다르다
|
||||
* (기본 `shop` / `route_path` 로 바꾼 값 / `no_route` 면 세그먼트 없음).
|
||||
* `/*` 로 쓰면 no_route 상점에서만 매칭이 깨진다 (공개 #85, engine-v1.58.0 `/*?`).
|
||||
*
|
||||
* 2. 파라미터 토큰 축 — `matchRoutePattern` 은 `:param` 을 리터럴로 취급한다.
|
||||
* scope 에 `:id` 라 적었는데 routes.json 이 `:product_code` 를 쓰면 세그먼트
|
||||
* 토큰을 아무리 고쳐도 **세 축 모두에서** 영영 매칭되지 않는다. 예외도 경고도
|
||||
* 없이 그 화면의 상태 토글만 사라지므로 정적 대조가 유일한 발견 경로다.
|
||||
*
|
||||
* scope 목록을 손으로 열거하지 않고 editor-spec 과 routes.json 양쪽에서 도출한다 —
|
||||
* 나중에 상점 화면이 늘어도 이 테스트가 그 화면까지 자동으로 검사한다.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
|
||||
import { matchStateItems } from '../../../../../../../resources/js/core/template-engine/layout-editor/utils/matchStateScope';
|
||||
|
||||
function findProjectRoot(startDir: string): string {
|
||||
let dir = startDir;
|
||||
while (dir !== path.dirname(dir)) {
|
||||
if (fs.existsSync(path.join(dir, 'artisan'))) return dir;
|
||||
dir = path.dirname(dir);
|
||||
}
|
||||
return path.resolve(startDir, '../../../../../../..');
|
||||
}
|
||||
|
||||
const REPO_ROOT = findProjectRoot(__dirname);
|
||||
|
||||
const spec = JSON.parse(
|
||||
fs.readFileSync(path.join(REPO_ROOT, 'modules/_bundled/sirsoft-ecommerce/editor-spec.json'), 'utf-8'),
|
||||
);
|
||||
const routesJson = JSON.parse(
|
||||
fs.readFileSync(path.join(REPO_ROOT, 'templates/_bundled/sirsoft-basic/routes.json'), 'utf-8'),
|
||||
);
|
||||
|
||||
/** routes.json 의 상점 주소 prefix 표현식 (`no_route ? '' : route_path ?? 'shop'`) */
|
||||
const SHOP_PREFIX_EXPR = /^\/\{\{[^}]*?no_route[\s\S]*?\}\}/;
|
||||
|
||||
/** 편집기가 상점 주소 설정을 평가했을 때 나올 수 있는 세 가지 첫 세그먼트 */
|
||||
const AXES: ReadonlyArray<[label: string, base: string]> = [
|
||||
['기본 상점 주소(shop)', '/shop'],
|
||||
['운영자가 바꾼 주소(store)', '/store'],
|
||||
['주소 없이 운영하는 상점(no_route)', ''],
|
||||
];
|
||||
|
||||
/** 상점 주소에 종속된 라우트를 축별로 평가한 path 목록 */
|
||||
function shopRoutePaths(base: string): string[] {
|
||||
return (routesJson.routes ?? [])
|
||||
.map((r: { path?: string }) => r.path)
|
||||
.filter((p: unknown): p is string => typeof p === 'string' && SHOP_PREFIX_EXPR.test(p))
|
||||
.map((p: string) => p.replace(SHOP_PREFIX_EXPR, base));
|
||||
}
|
||||
|
||||
/**
|
||||
* editor-spec 의 상점 route scope 목록 (그룹의 첫 상태 id 를 식별자로 함께 들고 간다).
|
||||
* 관리자 라우트(`*/admin/...`)는 상점 주소와 무관하므로 제외한다.
|
||||
*/
|
||||
const shopScopes: Array<{ match: string; probeItemId: string }> = (spec.states?.groups ?? [])
|
||||
.filter(
|
||||
(g: { scope?: { kind?: string; match?: unknown }; items?: unknown[] }) =>
|
||||
g.scope?.kind === 'route' &&
|
||||
typeof g.scope?.match === 'string' &&
|
||||
!g.scope.match.includes('/admin/') &&
|
||||
Array.isArray(g.items) &&
|
||||
g.items.length > 0,
|
||||
)
|
||||
.map((g: { scope: { match: string }; items: Array<{ id: string }> }) => ({
|
||||
match: g.scope.match,
|
||||
probeItemId: g.items[0].id,
|
||||
}));
|
||||
|
||||
describe('상점 상태 그룹 scope ↔ routes.json 라우트 패리티', () => {
|
||||
it('검사 모집단이 비어 있지 않다 (빈 모집단으로 통과하는 것을 막는다)', () => {
|
||||
expect(shopScopes.length).toBeGreaterThan(0);
|
||||
expect(shopRoutePaths('/shop').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
describe.each(AXES)('%s', (_label, base) => {
|
||||
it.each(shopScopes.map((s) => [s.match, s.probeItemId]))(
|
||||
'scope %s 의 상태가 실제 라우트에서 노출된다',
|
||||
(scopeMatch: string, probeItemId: string) => {
|
||||
const candidates = shopRoutePaths(base);
|
||||
|
||||
// 실제 소비자(matchStateItems)로 판정해 정규식 의미까지 함께 고정한다.
|
||||
const hit = candidates.find((routePath) =>
|
||||
matchStateItems(spec.states.groups, { kind: 'route', match: routePath }).some(
|
||||
(i) => i.id === probeItemId,
|
||||
),
|
||||
);
|
||||
|
||||
expect(
|
||||
hit,
|
||||
`scope "${scopeMatch}" 가 이 축의 어떤 라우트와도 매칭되지 않는다.\n` +
|
||||
` 후보 라우트: ${candidates.join(', ')}`,
|
||||
).toBeDefined();
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -26,6 +26,7 @@ use Modules\Sirsoft\Ecommerce\Services\OrderService;
|
||||
use Modules\Sirsoft\Ecommerce\Services\StockService;
|
||||
use Modules\Sirsoft\Ecommerce\Services\TempOrderService;
|
||||
use Modules\Sirsoft\Ecommerce\Services\UserAddressService;
|
||||
use Modules\Sirsoft\Ecommerce\Support\ShopPathResolver;
|
||||
|
||||
/**
|
||||
* 주문 공유 컨트롤러 (회원/비회원 공용 + 비회원 토큰 후속 액션)
|
||||
@@ -193,7 +194,9 @@ class OrderController extends PublicBaseController
|
||||
return ResponseHelper::error(
|
||||
'sirsoft-ecommerce::exceptions.order_not_found',
|
||||
404,
|
||||
['redirect_to' => '/shop/guest/orders']
|
||||
// 상점 주소 설정(route_path/no_route)을 반영해야 주소를 바꾼 상점에서도
|
||||
// 실제 존재하는 비회원 조회 화면으로 안내된다 (공개 #85).
|
||||
['redirect_to' => ShopPathResolver::path('guest/orders')]
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+4
-1
@@ -19,6 +19,7 @@ use Modules\Sirsoft\Ecommerce\Models\Order;
|
||||
use Modules\Sirsoft\Ecommerce\Models\TempOrder;
|
||||
use Modules\Sirsoft\Ecommerce\Services\CurrencyConversionService;
|
||||
use Modules\Sirsoft\Ecommerce\Services\EcommerceSettingsService;
|
||||
use Modules\Sirsoft\Ecommerce\Support\ShopPathResolver;
|
||||
|
||||
/**
|
||||
* 주문 생성 공통 흐름 Trait
|
||||
@@ -218,7 +219,9 @@ trait HandlesOrderCreation
|
||||
{
|
||||
$responseData = [
|
||||
'order' => $orderResource,
|
||||
'redirect_url' => "/shop/orders/{$order->order_number}/complete",
|
||||
// 상점 주소는 운영자 설정이다 — 기본값 리터럴을 내려보내면 주소를 바꾼 상점에서
|
||||
// 결제를 마친 손님이 존재하지 않는 화면으로 이동한다 (공개 #85).
|
||||
'redirect_url' => ShopPathResolver::path("orders/{$order->order_number}/complete"),
|
||||
'requires_pg_payment' => $requiresPg,
|
||||
];
|
||||
|
||||
|
||||
+6
-3
@@ -11,6 +11,7 @@ use Modules\Sirsoft\Ecommerce\Models\ProductInquiry;
|
||||
use Modules\Sirsoft\Ecommerce\Module;
|
||||
use Modules\Sirsoft\Ecommerce\Repositories\Contracts\OrderCancelRepositoryInterface;
|
||||
use Modules\Sirsoft\Ecommerce\Services\CurrencyConversionService;
|
||||
use Modules\Sirsoft\Ecommerce\Support\ShopPathResolver;
|
||||
|
||||
/**
|
||||
* 이커머스 알림 데이터 필터 리스너
|
||||
@@ -418,16 +419,18 @@ class EcommerceNotificationDataListener implements HookListenerInterface
|
||||
// ──────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 비회원 주문 조회 화면 경로
|
||||
* 비회원 주문 조회 화면의 기준 경로 (상점 주소 설정 하위)
|
||||
*
|
||||
* 후속 이메일 발송 이슈가 비회원 주문 조회 URL을 만들 때 사용하는 경로 기준이다.
|
||||
* 비회원 본인 확인은 주문번호 + 전화번호 + 조회 비밀번호 입력을 전제로 하므로
|
||||
* 주문번호를 쿼리에 노출하지 않고 순수 경로만 제공한다.
|
||||
*
|
||||
* 앞의 상점 주소는 운영자 설정이므로 ShopPathResolver 가 붙인다 — 상수로 `/shop` 을
|
||||
* 박아 두면 주소를 바꾼 상점의 안내 메일이 존재하지 않는 화면을 가리킨다 (공개 #85).
|
||||
* 경로는 비회원 주문 조회 라우트(templates/sirsoft-basic/routes.json) 및
|
||||
* OrderController::showByOrderNumber 의 비회원 redirect_to 와 동일하게 유지한다.
|
||||
*/
|
||||
private const GUEST_ORDER_LOOKUP_PATH = '/shop/guest/orders';
|
||||
private const GUEST_ORDER_LOOKUP_SUFFIX = 'guest/orders';
|
||||
|
||||
/**
|
||||
* 주문 알림 데이터 배열을 구성합니다.
|
||||
@@ -445,7 +448,7 @@ class EcommerceNotificationDataListener implements HookListenerInterface
|
||||
|
||||
if ($order->isGuestOrder()) {
|
||||
$name = $order->getOrdererName() ?? '';
|
||||
$orderUrl = "{$baseUrl}".self::GUEST_ORDER_LOOKUP_PATH;
|
||||
$orderUrl = "{$baseUrl}".ShopPathResolver::path(self::GUEST_ORDER_LOOKUP_SUFFIX);
|
||||
} else {
|
||||
$name = $order->user?->name ?? '';
|
||||
$orderUrl = "{$baseUrl}/mypage/orders/{$order->order_number}";
|
||||
|
||||
@@ -10,6 +10,7 @@ use App\Support\Query\BoundedCount;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Modules\Sirsoft\Ecommerce\Http\Resources\Traits\HasMultiCurrencyPrices;
|
||||
use Modules\Sirsoft\Ecommerce\Services\ProductService;
|
||||
use Modules\Sirsoft\Ecommerce\Support\ShopPathResolver;
|
||||
|
||||
/**
|
||||
* 통합 검색에 상품 검색 결과를 제공하는 리스너
|
||||
@@ -268,7 +269,8 @@ class SearchProductsListener implements HookListenerInterface
|
||||
'multi_currency_selling_price' => $this->buildMultiCurrencyPrices($product->selling_price),
|
||||
'multi_currency_list_price' => $this->buildMultiCurrencyPrices($product->list_price),
|
||||
'labels' => $labels,
|
||||
'url' => '/shop/products/'.$product->product_code,
|
||||
// 상점 주소는 운영자 설정 — 기본값 리터럴은 주소를 바꾼 상점에서 죽은 링크가 된다 (공개 #85)
|
||||
'url' => ShopPathResolver::path('products/'.$product->product_code),
|
||||
'review_count' => (int) ($product->review_count ?? 0),
|
||||
'rating_avg' => $product->rating_avg !== null ? round((float) $product->rating_avg, 1) : 0.0,
|
||||
];
|
||||
|
||||
@@ -9,6 +9,7 @@ use App\Jobs\GenerateSitemapJob;
|
||||
use App\Seo\Contracts\SeoCacheManagerInterface;
|
||||
use App\Seo\SitemapIndexer;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Modules\Sirsoft\Ecommerce\Support\ShopPathResolver;
|
||||
|
||||
/**
|
||||
* 카테고리 변경 시 SEO 캐시 무효화 리스너
|
||||
@@ -142,9 +143,8 @@ class SeoCategoryCacheListener implements HookListenerInterface
|
||||
&& (bool) g7_module_settings('sirsoft-ecommerce', 'seo.seo_category', true);
|
||||
|
||||
if ($visible) {
|
||||
$routePath = g7_module_settings('sirsoft-ecommerce', 'basic_info.route_path', 'shop');
|
||||
$indexer->indexResource('category', $category->id, 'sirsoft-ecommerce', [[
|
||||
'url' => "/{$routePath}/category/{$category->slug}",
|
||||
'url' => ShopPathResolver::path("category/{$category->slug}"),
|
||||
'lastmod' => $category->updated_at?->toW3cString(),
|
||||
'changefreq' => SitemapChangeFreq::Weekly->value,
|
||||
'priority' => 0.6,
|
||||
|
||||
@@ -11,6 +11,7 @@ use App\Seo\SeoCacheRegenerator;
|
||||
use App\Seo\SitemapIndexer;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Modules\Sirsoft\Ecommerce\Enums\ProductDisplayStatus;
|
||||
use Modules\Sirsoft\Ecommerce\Support\ShopPathResolver;
|
||||
|
||||
/**
|
||||
* 상품 변경 시 SEO 캐시 무효화 리스너
|
||||
@@ -160,9 +161,8 @@ class SeoProductCacheListener implements HookListenerInterface
|
||||
&& (bool) g7_module_settings('sirsoft-ecommerce', 'seo.seo_product_detail', true);
|
||||
|
||||
if ($visible) {
|
||||
$routePath = g7_module_settings('sirsoft-ecommerce', 'basic_info.route_path', 'shop');
|
||||
$indexer->indexResource('product', $product->id, 'sirsoft-ecommerce', [[
|
||||
'url' => "/{$routePath}/products/{$product->id}",
|
||||
'url' => ShopPathResolver::path("products/{$product->id}"),
|
||||
'lastmod' => $product->updated_at?->toW3cString(),
|
||||
'changefreq' => SitemapChangeFreq::Weekly->value,
|
||||
'priority' => 0.8,
|
||||
@@ -184,8 +184,8 @@ class SeoProductCacheListener implements HookListenerInterface
|
||||
/**
|
||||
* 상품 상세 페이지의 SEO 캐시를 즉시 재생성합니다.
|
||||
*
|
||||
* URL 구성: /{route_path}/products/{id}
|
||||
* route_path는 이커머스 모듈 설정에서 조회합니다.
|
||||
* URL 구성: {상점 기준 경로}/products/{id}
|
||||
* 기준 경로는 상점 주소 설정(route_path / no_route)을 ShopPathResolver 가 해석한 값입니다.
|
||||
*
|
||||
* @param array $args 훅 인자 배열
|
||||
*/
|
||||
@@ -199,8 +199,7 @@ class SeoProductCacheListener implements HookListenerInterface
|
||||
|
||||
try {
|
||||
$regenerator = app(SeoCacheRegenerator::class);
|
||||
$routePath = g7_module_settings('sirsoft-ecommerce', 'basic_info.route_path', 'shop');
|
||||
$url = "/{$routePath}/products/{$product->id}";
|
||||
$url = ShopPathResolver::path("products/{$product->id}");
|
||||
$regenerator->renderAndCache($url);
|
||||
|
||||
Log::debug('[SEO] Product detail cache regenerated', [
|
||||
|
||||
@@ -7,6 +7,7 @@ use App\Seo\AbstractSitemapContributor;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Modules\Sirsoft\Ecommerce\Repositories\Contracts\CategoryRepositoryInterface;
|
||||
use Modules\Sirsoft\Ecommerce\Repositories\Contracts\ProductRepositoryInterface;
|
||||
use Modules\Sirsoft\Ecommerce\Support\ShopPathResolver;
|
||||
|
||||
/**
|
||||
* Ecommerce 모듈 Sitemap 기여자
|
||||
@@ -74,12 +75,12 @@ class EcommerceSitemapContributor extends AbstractSitemapContributor
|
||||
*/
|
||||
private function collectUrls(): iterable
|
||||
{
|
||||
$routePath = g7_module_settings('sirsoft-ecommerce', 'basic_info.route_path') ?? 'shop';
|
||||
|
||||
// 상점 주소 설정(route_path / no_route)을 반영한다 — 주소를 바꾼 상점의 사이트맵이
|
||||
// 기본값 주소를 싣고 나가면 검색엔진이 존재하지 않는 URL 을 수집한다 (공개 #85).
|
||||
// 상품 목록 페이지 — 'SEO 제공 페이지' 토글 OFF 시 제외
|
||||
if ((bool) g7_module_settings('sirsoft-ecommerce', 'seo.seo_shop_index', true)) {
|
||||
yield [
|
||||
'url' => "/{$routePath}/products",
|
||||
'url' => ShopPathResolver::path('products'),
|
||||
'changefreq' => SitemapChangeFreq::Daily->value,
|
||||
'priority' => 0.7,
|
||||
'resource_type' => 'shop_index',
|
||||
@@ -91,7 +92,7 @@ class EcommerceSitemapContributor extends AbstractSitemapContributor
|
||||
if ((bool) g7_module_settings('sirsoft-ecommerce', 'seo.seo_category', true)) {
|
||||
foreach ($this->categoryRepository->streamActiveForSitemap() as $category) {
|
||||
yield [
|
||||
'url' => "/{$routePath}/category/{$category->slug}",
|
||||
'url' => ShopPathResolver::path("category/{$category->slug}"),
|
||||
'lastmod' => $category->updated_at?->toW3cString(),
|
||||
'changefreq' => SitemapChangeFreq::Weekly->value,
|
||||
'priority' => 0.6,
|
||||
@@ -105,7 +106,7 @@ class EcommerceSitemapContributor extends AbstractSitemapContributor
|
||||
if ((bool) g7_module_settings('sirsoft-ecommerce', 'seo.seo_product_detail', true)) {
|
||||
foreach ($this->productRepository->streamVisibleForSitemap() as $product) {
|
||||
yield [
|
||||
'url' => "/{$routePath}/products/{$product->id}",
|
||||
'url' => ShopPathResolver::path("products/{$product->id}"),
|
||||
'lastmod' => $product->updated_at?->toW3cString(),
|
||||
'changefreq' => SitemapChangeFreq::Weekly->value,
|
||||
'priority' => 0.8,
|
||||
|
||||
@@ -2,15 +2,17 @@
|
||||
|
||||
namespace Modules\Sirsoft\Ecommerce\Services;
|
||||
|
||||
use App\Contracts\Repositories\UserRepositoryInterface;
|
||||
use App\Extension\HookManager;
|
||||
use App\Helpers\PermissionHelper;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\ModelNotFoundException;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Modules\Sirsoft\Ecommerce\Models\ProductInquiry;
|
||||
use Modules\Sirsoft\Ecommerce\Repositories\Contracts\ProductInquiryRepositoryInterface;
|
||||
use Modules\Sirsoft\Ecommerce\Repositories\Contracts\ProductRepositoryInterface;
|
||||
use Modules\Sirsoft\Ecommerce\Support\ShopPathResolver;
|
||||
|
||||
/**
|
||||
* 상품 1:1 문의 서비스
|
||||
@@ -26,11 +28,13 @@ class ProductInquiryService
|
||||
* @param ProductInquiryRepositoryInterface $repository 문의 리포지토리
|
||||
* @param ProductRepositoryInterface $productRepository 상품 리포지토리
|
||||
* @param EcommerceSettingsService $settingsService 이커머스 설정 서비스
|
||||
* @param UserRepositoryInterface $userRepository 사용자 리포지토리 (작성자 이름 배치 조회)
|
||||
*/
|
||||
public function __construct(
|
||||
protected ProductInquiryRepositoryInterface $repository,
|
||||
protected ProductRepositoryInterface $productRepository,
|
||||
protected EcommerceSettingsService $settingsService
|
||||
protected EcommerceSettingsService $settingsService,
|
||||
protected UserRepositoryInterface $userRepository
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -63,13 +67,13 @@ class ProductInquiryService
|
||||
if (! $boardSlug) {
|
||||
return [
|
||||
'items' => [],
|
||||
'meta' => [
|
||||
'board_settings' => $this->defaultBoardSettings(),
|
||||
'meta' => [
|
||||
'board_settings' => $this->defaultBoardSettings(),
|
||||
'inquiry_available' => false,
|
||||
'total' => 0,
|
||||
'current_page' => $page,
|
||||
'per_page' => $perPage,
|
||||
'last_page' => 1,
|
||||
'total' => 0,
|
||||
'current_page' => $page,
|
||||
'per_page' => $perPage,
|
||||
'last_page' => 1,
|
||||
],
|
||||
];
|
||||
}
|
||||
@@ -118,7 +122,7 @@ class ProductInquiryService
|
||||
// user_id 일괄 조회 (N+1 방지)
|
||||
$userIds = $pagePivots->map(fn ($pivot) => $posts[$pivot->inquirable_id]['user_id'] ?? null)
|
||||
->filter()->unique()->values()->all();
|
||||
$userMap = User::whereIn('id', $userIds)->pluck('name', 'id');
|
||||
$userMap = $this->userRepository->getNamesByIds($userIds);
|
||||
|
||||
$items = $pagePivots->map(function ($pivot) use ($posts, $currentUserId, $userMap) {
|
||||
$post = $posts[$pivot->inquirable_id] ?? null;
|
||||
@@ -128,19 +132,19 @@ class ProductInquiryService
|
||||
$name = $userId ? ($userMap[$userId] ?? $post['author_name'] ?? null) : ($post['author_name'] ?? null);
|
||||
|
||||
return [
|
||||
'id' => $pivot->id,
|
||||
'post_id' => $pivot->inquirable_id,
|
||||
'user_id' => $userId,
|
||||
'id' => $pivot->id,
|
||||
'post_id' => $pivot->inquirable_id,
|
||||
'user_id' => $userId,
|
||||
'author_name' => $this->maskAuthorName($name),
|
||||
'title' => $post['title'] ?? null,
|
||||
'category' => $post['category'] ?? null,
|
||||
'content' => $post['content'] ?? null,
|
||||
'is_secret' => $post['is_secret'] ?? false,
|
||||
'is_owner' => $isOwner,
|
||||
'title' => $post['title'] ?? null,
|
||||
'category' => $post['category'] ?? null,
|
||||
'content' => $post['content'] ?? null,
|
||||
'is_secret' => $post['is_secret'] ?? false,
|
||||
'is_owner' => $isOwner,
|
||||
'is_answered' => $pivot->is_answered ?? false,
|
||||
'answered_at' => $pivot->answered_at?->toIso8601String(),
|
||||
'created_at' => $pivot->created_at?->toIso8601String(),
|
||||
'reply' => $post['reply'] ?? null,
|
||||
'created_at' => $pivot->created_at?->toIso8601String(),
|
||||
'reply' => $post['reply'] ?? null,
|
||||
'attachments' => $post['attachments'] ?? [],
|
||||
];
|
||||
})->values()->all();
|
||||
@@ -149,14 +153,14 @@ class ProductInquiryService
|
||||
|
||||
return [
|
||||
'items' => $items,
|
||||
'meta' => [
|
||||
'board_settings' => $boardSettings,
|
||||
'meta' => [
|
||||
'board_settings' => $boardSettings,
|
||||
'inquiry_available' => (bool) $boardSlug,
|
||||
'current_page' => $page,
|
||||
'per_page' => $perPage,
|
||||
'total' => $total,
|
||||
'last_page' => max(1, $lastPage),
|
||||
'abilities' => [
|
||||
'current_page' => $page,
|
||||
'per_page' => $perPage,
|
||||
'total' => $total,
|
||||
'last_page' => max(1, $lastPage),
|
||||
'abilities' => [
|
||||
'can_update' => PermissionHelper::check('sirsoft-ecommerce.inquiries.update', Auth::user()),
|
||||
'can_delete' => PermissionHelper::check('sirsoft-ecommerce.inquiries.delete', Auth::user()),
|
||||
],
|
||||
@@ -172,14 +176,14 @@ class ProductInquiryService
|
||||
private function defaultBoardSettings(): array
|
||||
{
|
||||
return [
|
||||
'secret_mode' => 'disabled',
|
||||
'categories' => [],
|
||||
'use_file_upload' => false,
|
||||
'max_file_count' => 5,
|
||||
'max_file_size' => 10485760,
|
||||
'secret_mode' => 'disabled',
|
||||
'categories' => [],
|
||||
'use_file_upload' => false,
|
||||
'max_file_count' => 5,
|
||||
'max_file_size' => 10485760,
|
||||
'allowed_extensions' => [],
|
||||
'min_title_length' => 2,
|
||||
'max_title_length' => 200,
|
||||
'min_title_length' => 2,
|
||||
'max_title_length' => 200,
|
||||
'min_content_length' => 10,
|
||||
'max_content_length' => 10000,
|
||||
];
|
||||
@@ -209,7 +213,7 @@ class ProductInquiryService
|
||||
$product = $this->productRepository->find($productId);
|
||||
|
||||
if (! $product) {
|
||||
throw new \Illuminate\Database\Eloquent\ModelNotFoundException(
|
||||
throw new ModelNotFoundException(
|
||||
__('sirsoft-ecommerce::messages.products.not_found')
|
||||
);
|
||||
}
|
||||
@@ -312,41 +316,42 @@ class ProductInquiryService
|
||||
$post = $posts[$inquiry->inquirable_id] ?? null;
|
||||
|
||||
return [
|
||||
'id' => $inquiry->id,
|
||||
'product_id' => $inquiry->product_id,
|
||||
'product' => $inquiry->product ? [
|
||||
'id' => $inquiry->product->id,
|
||||
'product_code' => $inquiry->product->product_code,
|
||||
'name' => $inquiry->product->getLocalizedName(),
|
||||
'id' => $inquiry->id,
|
||||
'product_id' => $inquiry->product_id,
|
||||
'product' => $inquiry->product ? [
|
||||
'id' => $inquiry->product->id,
|
||||
'product_code' => $inquiry->product->product_code,
|
||||
'name' => $inquiry->product->getLocalizedName(),
|
||||
'thumbnail_url' => $inquiry->product->getThumbnailUrl(),
|
||||
'url' => '/' . ltrim($this->settingsService->getSetting('basic_info.route_path', 'shop'), '/') . '/products/' . $inquiry->product->product_code,
|
||||
// 주소 없이 운영하는 상점(no_route)까지 반영해야 실제 상품 화면을 가리킨다 (공개 #85)
|
||||
'url' => ShopPathResolver::path('products/'.$inquiry->product->product_code),
|
||||
] : null,
|
||||
'product_name' => $this->localizeProductName($inquiry->product_name_snapshot),
|
||||
'is_answered' => $inquiry->is_answered,
|
||||
'answered_at' => $inquiry->answered_at?->toIso8601String(),
|
||||
'created_at' => $inquiry->created_at?->toIso8601String(),
|
||||
'updated_at' => $inquiry->updated_at?->toIso8601String(),
|
||||
'product_name' => $this->localizeProductName($inquiry->product_name_snapshot),
|
||||
'is_answered' => $inquiry->is_answered,
|
||||
'answered_at' => $inquiry->answered_at?->toIso8601String(),
|
||||
'created_at' => $inquiry->created_at?->toIso8601String(),
|
||||
'updated_at' => $inquiry->updated_at?->toIso8601String(),
|
||||
// 게시판 Post 데이터 (게시판 미연동 시 null)
|
||||
'title' => $post['title'] ?? null,
|
||||
'category' => $post['category'] ?? null,
|
||||
'content' => $post['content'] ?? null,
|
||||
'is_secret' => $post['is_secret'] ?? false,
|
||||
'reply' => $post['reply'] ?? null,
|
||||
'attachments' => $post['attachments'] ?? [],
|
||||
'title' => $post['title'] ?? null,
|
||||
'category' => $post['category'] ?? null,
|
||||
'content' => $post['content'] ?? null,
|
||||
'is_secret' => $post['is_secret'] ?? false,
|
||||
'reply' => $post['reply'] ?? null,
|
||||
'attachments' => $post['attachments'] ?? [],
|
||||
];
|
||||
})->values()->all();
|
||||
|
||||
return [
|
||||
'items' => $items,
|
||||
'meta' => [
|
||||
'current_page' => $paginator->currentPage(),
|
||||
'per_page' => $paginator->perPage(),
|
||||
'total' => $paginator->total(),
|
||||
'last_page' => $paginator->lastPage(),
|
||||
'from' => $paginator->firstItem(),
|
||||
'to' => $paginator->lastItem(),
|
||||
'meta' => [
|
||||
'current_page' => $paginator->currentPage(),
|
||||
'per_page' => $paginator->perPage(),
|
||||
'total' => $paginator->total(),
|
||||
'last_page' => $paginator->lastPage(),
|
||||
'from' => $paginator->firstItem(),
|
||||
'to' => $paginator->lastItem(),
|
||||
'inquiry_available' => (bool) $boardSlug,
|
||||
'abilities' => [
|
||||
'abilities' => [
|
||||
'can_update' => PermissionHelper::check('sirsoft-ecommerce.inquiries.update', Auth::user()),
|
||||
'can_delete' => PermissionHelper::check('sirsoft-ecommerce.inquiries.delete', Auth::user()),
|
||||
],
|
||||
@@ -394,7 +399,7 @@ class ProductInquiryService
|
||||
|
||||
Log::info('상품 문의 수정 완료', [
|
||||
'inquiry_id' => $inquiryId,
|
||||
'user_id' => Auth::id(),
|
||||
'user_id' => Auth::id(),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -442,7 +447,7 @@ class ProductInquiryService
|
||||
|
||||
Log::info('상품 문의 삭제 완료', [
|
||||
'inquiry_id' => $inquiryId,
|
||||
'user_id' => Auth::id(),
|
||||
'user_id' => Auth::id(),
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Sirsoft\Ecommerce\Support;
|
||||
|
||||
/**
|
||||
* 상점 화면 경로의 SSoT.
|
||||
*
|
||||
* 상점 주소는 운영자 설정이다 — `basic_info.route_path` 로 바꿀 수 있고,
|
||||
* `basic_info.no_route` 를 켜면 주소 세그먼트 없이 루트에 붙는다. 그런데 서버가
|
||||
* 만들어 내보내는 상점 주소(주문 완료 이동 · 비회원 주문 조회 안내 · 알림 메일의
|
||||
* 주문 조회 링크 · 통합검색 결과 링크 · 사이트맵/SEO 캐시 URL)는 각 지점에서
|
||||
* `/shop` 리터럴이나 `"/{$routePath}/..."` 조립으로 흩어져 있었다.
|
||||
*
|
||||
* 기본값이 아닌 상점에서는 그 주소가 존재하지 않는 페이지를 가리키는데, 서버는
|
||||
* 문자열을 만들어 내려보냈을 뿐이라 예외도 로그도 남지 않는다. 링크만 조용히 죽는다.
|
||||
*
|
||||
* 설정 접근은 `g7_module_settings()` 로 고정한다. 매 요청 부팅 시 적재된 배열을 읽으므로
|
||||
* 파일 I/O 가 없고, 테스트에서 `Config::set('g7_settings.modules.*')` 로 축을 주입할 수 있다.
|
||||
*
|
||||
* ## 공개 계약 (다른 확장이 소비한다)
|
||||
*
|
||||
* 상점 주소가 필요한 확장은 자체 계산을 두지 말고 이 클래스를 거친다. 결제 플러그인
|
||||
* 4종(tosspayments / pay_kginicis / pay_nhnkcp / pay_nicepayments)이 결제 완료·실패
|
||||
* 리다이렉트 주소를 만들 때 `base()` 를 호출한다. 같은 계산이 여러 벌로 흩어지면
|
||||
* 주소 규칙이 바뀔 때 한 곳을 빠뜨리게 되고, 그 확장에서만 링크가 조용히 죽는다.
|
||||
*
|
||||
* 소비 확장은 `plugin.json` / `module.json` 의 `dependencies.modules.sirsoft-ecommerce`
|
||||
* 를 이 계약이 도입된 `>=1.1.0` 이상으로 선언해야 한다.
|
||||
*
|
||||
* 시그니처를 바꾸거나 제거할 때는 위 소비 확장을 전수 확인하고 각 확장의 최소 버전
|
||||
* 제약을 함께 올린다 (CLAUDE.md "버전 동기화 의무" ②).
|
||||
*
|
||||
* @since 1.1.0
|
||||
*/
|
||||
class ShopPathResolver
|
||||
{
|
||||
/** 상점 주소 설정 키 */
|
||||
public const ROUTE_PATH_KEY = 'basic_info.route_path';
|
||||
|
||||
/** 주소 없이 운영 여부 설정 키 */
|
||||
public const NO_ROUTE_KEY = 'basic_info.no_route';
|
||||
|
||||
/** 기본 상점 주소 (설정 미지정·빈 값 시 폴백) */
|
||||
public const DEFAULT_ROUTE_PATH = 'shop';
|
||||
|
||||
/**
|
||||
* 상점 화면의 기준 경로를 반환합니다.
|
||||
*
|
||||
* `no_route` 가 켜져 있으면 빈 문자열이다 — 상점이 루트에 붙어 있어 앞에 붙일
|
||||
* 세그먼트가 없다는 뜻이며, `path()` 가 이를 이중 슬래시 없이 이어 붙인다.
|
||||
*
|
||||
* @return string 앞에만 슬래시가 붙은 기준 경로 (예: `/shop`), 주소 미사용 시 빈 문자열
|
||||
*/
|
||||
public static function base(): string
|
||||
{
|
||||
if (g7_module_settings('sirsoft-ecommerce', self::NO_ROUTE_KEY)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$routePath = trim((string) g7_module_settings('sirsoft-ecommerce', self::ROUTE_PATH_KEY, self::DEFAULT_ROUTE_PATH), '/');
|
||||
|
||||
return '/'.($routePath !== '' ? $routePath : self::DEFAULT_ROUTE_PATH);
|
||||
}
|
||||
|
||||
/**
|
||||
* 기준 경로에 하위 경로를 이어 붙인 상점 화면 경로를 반환합니다.
|
||||
*
|
||||
* 슬래시 중복은 한 겹으로 정규화한다 (`no_route` + 앞 슬래시 붙은 하위 경로 조합에서
|
||||
* `//products` 가 만들어지는 것을 막는다). 코어 라우트 해석기가 같은 이유로 같은
|
||||
* 정규화를 수행한다 (`app/Seo/TemplateRouteResolver.php`).
|
||||
*
|
||||
* @param string $suffix 기준 경로 하위의 화면 경로 (앞 슬래시 유무 무관)
|
||||
* @return string 정규화된 상점 화면 경로
|
||||
*/
|
||||
public static function path(string $suffix): string
|
||||
{
|
||||
return (string) preg_replace('#/+#', '/', self::base().'/'.ltrim($suffix, '/'));
|
||||
}
|
||||
}
|
||||
+81
@@ -3,6 +3,7 @@
|
||||
namespace Modules\Sirsoft\Ecommerce\Tests\Feature\Http\Controllers\Public;
|
||||
|
||||
use App\Extension\HookManager;
|
||||
use Illuminate\Support\Facades\Config;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Str;
|
||||
use Modules\Sirsoft\Ecommerce\Enums\OrderStatusEnum;
|
||||
@@ -18,6 +19,7 @@ use Modules\Sirsoft\Ecommerce\Models\TempOrder;
|
||||
use Modules\Sirsoft\Ecommerce\Services\EcommerceSettingsService;
|
||||
use Modules\Sirsoft\Ecommerce\Services\PaymentMethodResolver;
|
||||
use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
|
||||
/**
|
||||
* 회원/비회원 공유 주문 (Public\OrderController) 테스트
|
||||
@@ -184,6 +186,51 @@ class OrderControllerTest extends ModuleTestCase
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 1-1. 주문 완료 이동 주소는 상점 주소 설정을 따른다 (공개 #85)
|
||||
*
|
||||
* 기본값 상점(`/shop`) · 운영자가 바꾼 주소(`/store`) · 주소 없이 운영(no_route) 3축.
|
||||
* 서버가 기본값을 리터럴로 내려보내면 뒤의 두 상점에서는 결제를 마친 손님이
|
||||
* 존재하지 않는 페이지로 이동한다.
|
||||
*
|
||||
* @param array<string, mixed> $basicInfo 상점 기본 설정
|
||||
*/
|
||||
#[DataProvider('shopRoutePathProvider')]
|
||||
public function test_주문_완료_이동주소가_상점_주소_설정을_따른다(array $basicInfo, string $expectedPrefix): void
|
||||
{
|
||||
Config::set('g7_settings.modules.sirsoft-ecommerce.basic_info', $basicInfo);
|
||||
|
||||
$this->createGuestTempOrder();
|
||||
|
||||
$response = $this->postJson(
|
||||
'/api/modules/sirsoft-ecommerce/user/orders',
|
||||
$this->guestOrderPayload(),
|
||||
['X-Cart-Key' => $this->cartKey]
|
||||
);
|
||||
|
||||
$response->assertStatus(201);
|
||||
|
||||
$orderNumber = $response->json('data.order.order_number');
|
||||
$this->assertSame(
|
||||
"{$expectedPrefix}/orders/{$orderNumber}/complete",
|
||||
$response->json('data.redirect_url')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 상점 주소 3축 (기본 / 변경 / 주소 없음)
|
||||
*
|
||||
* @return array<string, array{0: array<string, mixed>, 1: string}>
|
||||
*/
|
||||
public static function shopRoutePathProvider(): array
|
||||
{
|
||||
return [
|
||||
'기본 상점 주소' => [['route_path' => 'shop'], '/shop'],
|
||||
'운영자가 바꾼 주소' => [['route_path' => 'store'], '/store'],
|
||||
'주소 없이 운영' => [['route_path' => 'shop', 'no_route' => true], ''],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 2. 비회원 주문은 is_first_order = false 로 저장된다 (이슈 #55 정책)
|
||||
*/
|
||||
@@ -1165,6 +1212,40 @@ class OrderControllerTest extends ModuleTestCase
|
||||
->assertJsonPath('errors.redirect_to', '/shop/guest/orders');
|
||||
}
|
||||
|
||||
/**
|
||||
* 20-1. 상점 주소를 바꾼 상점에서는 비회원 조회 안내도 그 주소를 가리킨다 (공개 #85)
|
||||
*
|
||||
* 기본값 `/shop` 을 리터럴로 내려보내면 주소를 바꾼 상점에서는 존재하지 않는 화면으로
|
||||
* 안내한다. 서버는 문자열을 만들어 보냈을 뿐이라 예외도 404 로그도 남지 않는다.
|
||||
*/
|
||||
public function test_상점_주소_변경시_비회원_lookup_redirect도_따라간다(): void
|
||||
{
|
||||
Config::set('g7_settings.modules.sirsoft-ecommerce.basic_info', ['route_path' => 'store']);
|
||||
|
||||
[$orderNumber] = $this->placeGuestOrderAndToken();
|
||||
|
||||
$this->getJson("/api/modules/sirsoft-ecommerce/user/orders/{$orderNumber}")
|
||||
->assertStatus(404)
|
||||
->assertJsonPath('errors.redirect_to', '/store/guest/orders');
|
||||
}
|
||||
|
||||
/**
|
||||
* 20-2. 주소 없이 운영하는 상점(no_route)에서는 세그먼트 없이 루트에 붙는다 (공개 #85)
|
||||
*/
|
||||
public function test_주소_없는_상점은_비회원_lookup_redirect가_루트_경로다(): void
|
||||
{
|
||||
Config::set('g7_settings.modules.sirsoft-ecommerce.basic_info', [
|
||||
'route_path' => 'shop',
|
||||
'no_route' => true,
|
||||
]);
|
||||
|
||||
[$orderNumber] = $this->placeGuestOrderAndToken();
|
||||
|
||||
$this->getJson("/api/modules/sirsoft-ecommerce/user/orders/{$orderNumber}")
|
||||
->assertStatus(404)
|
||||
->assertJsonPath('errors.redirect_to', '/guest/orders');
|
||||
}
|
||||
|
||||
/**
|
||||
* 21. 토큰으로 주문 취소 성공 (결제완료 상태)
|
||||
*/
|
||||
|
||||
+53
@@ -4,6 +4,7 @@ namespace Modules\Sirsoft\Ecommerce\Tests\Feature\Http\Controllers\Public;
|
||||
|
||||
use App\Extension\HookManager;
|
||||
use Modules\Sirsoft\Ecommerce\Models\Product;
|
||||
use Modules\Sirsoft\Ecommerce\Models\ProductInquiry;
|
||||
use Modules\Sirsoft\Ecommerce\Services\EcommerceSettingsService;
|
||||
use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
@@ -75,6 +76,58 @@ class PublicProductInquiryControllerTest extends ModuleTestCase
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 작성자 이름은 회원 계정에서 배치로 모아 온 뒤 마스킹되어 내려간다.
|
||||
*
|
||||
* 이름은 행마다 조회하면 N+1 이 되므로 표시할 ID 만 모아 한 번에 읽는다
|
||||
* (`UserRepositoryInterface::getNamesByIds`). 이 경로가 비면 목록의 작성자
|
||||
* 칸이 게시글 스냅샷 이름으로 조용히 대체되어, 개명 후에도 옛 이름이 남는다.
|
||||
*/
|
||||
#[Test]
|
||||
public function 문의_목록의_작성자_이름은_회원_계정에서_읽어_마스킹된다(): void
|
||||
{
|
||||
app(EcommerceSettingsService::class)->setSetting('inquiry.board_slug', 'test-board');
|
||||
|
||||
$user = $this->createUser();
|
||||
$user->forceFill(['name' => '홍길동'])->save();
|
||||
|
||||
$pivot = ProductInquiry::create([
|
||||
'product_id' => $this->product->id,
|
||||
'inquirable_type' => 'board_post',
|
||||
'inquirable_id' => 777,
|
||||
'user_id' => $user->id,
|
||||
]);
|
||||
|
||||
HookManager::addFilter(
|
||||
'sirsoft-ecommerce.inquiry.get_settings',
|
||||
fn ($defaults) => $defaults,
|
||||
priority: 1
|
||||
);
|
||||
HookManager::addFilter(
|
||||
'sirsoft-ecommerce.inquiry.get_by_ids',
|
||||
fn () => [[
|
||||
'id' => $pivot->inquirable_id,
|
||||
'user_id' => $user->id,
|
||||
// 게시글 스냅샷에는 다른 이름이 들어 있다 — 회원 계정 이름이 이겨야 한다.
|
||||
'author_name' => '스냅샷이름',
|
||||
'title' => '문의 제목',
|
||||
'is_secret' => false,
|
||||
]],
|
||||
priority: 1
|
||||
);
|
||||
|
||||
$response = $this->getJson(
|
||||
"/api/modules/sirsoft-ecommerce/products/{$this->product->id}/inquiries"
|
||||
);
|
||||
|
||||
$response->assertOk();
|
||||
|
||||
// 존재 확정 후 값 비교 — 항목이 비면 이름 비교는 아무것도 증명하지 못한다.
|
||||
$items = $response->json('data.items');
|
||||
$this->assertCount(1, $items);
|
||||
$this->assertSame('홍*동', $items[0]['author_name']);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function board_slug_미설정_시_빈_목록과_inquiry_available_false를_반환한다(): void
|
||||
{
|
||||
|
||||
+50
-17
@@ -3,6 +3,8 @@
|
||||
namespace Modules\Sirsoft\Ecommerce\Tests\Feature\Http\Controllers\Public;
|
||||
|
||||
use App\Extension\HookManager;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Config;
|
||||
use Modules\Sirsoft\Ecommerce\Models\Product;
|
||||
use Modules\Sirsoft\Ecommerce\Models\ProductInquiry;
|
||||
use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase;
|
||||
@@ -18,7 +20,7 @@ use PHPUnit\Framework\Attributes\Test;
|
||||
*/
|
||||
class UserInquiryControllerTest extends ModuleTestCase
|
||||
{
|
||||
private \App\Models\User $user;
|
||||
private User $user;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
@@ -58,7 +60,7 @@ class UserInquiryControllerTest extends ModuleTestCase
|
||||
{
|
||||
$product = Product::factory()->create();
|
||||
ProductInquiry::factory()->create([
|
||||
'user_id' => $this->user->id,
|
||||
'user_id' => $this->user->id,
|
||||
'product_id' => $product->id,
|
||||
'is_answered' => false,
|
||||
]);
|
||||
@@ -73,13 +75,44 @@ class UserInquiryControllerTest extends ModuleTestCase
|
||||
$this->assertCount(1, $data['items']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 문의 목록의 상품 링크가 상점 주소 설정을 따르는지 확인 (공개 #85)
|
||||
*
|
||||
* 종전에는 route_path 만 읽고 no_route 를 무시해, 주소 없이 운영하는 상점의
|
||||
* 문의 목록이 `/shop/products/...` 라는 존재하지 않는 화면을 가리켰다.
|
||||
*/
|
||||
#[Test]
|
||||
public function 문의_목록의_상품_링크가_상점_주소_설정을_따른다(): void
|
||||
{
|
||||
Config::set('g7_settings.modules.sirsoft-ecommerce.basic_info', [
|
||||
'route_path' => 'shop',
|
||||
'no_route' => true,
|
||||
]);
|
||||
|
||||
$product = Product::factory()->create();
|
||||
ProductInquiry::factory()->create([
|
||||
'user_id' => $this->user->id,
|
||||
'product_id' => $product->id,
|
||||
'is_answered' => false,
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($this->user)
|
||||
->getJson('/api/modules/sirsoft-ecommerce/user/inquiries');
|
||||
|
||||
$response->assertOk();
|
||||
$this->assertSame(
|
||||
"/products/{$product->product_code}",
|
||||
$response->json('data.items.0.product.url')
|
||||
);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function 다른_사용자의_문의는_조회되지_않는다(): void
|
||||
{
|
||||
$otherUser = $this->createUser();
|
||||
$product = Product::factory()->create();
|
||||
ProductInquiry::factory()->create([
|
||||
'user_id' => $otherUser->id,
|
||||
'user_id' => $otherUser->id,
|
||||
'product_id' => $product->id,
|
||||
]);
|
||||
|
||||
@@ -99,13 +132,13 @@ class UserInquiryControllerTest extends ModuleTestCase
|
||||
{
|
||||
$product = Product::factory()->create();
|
||||
ProductInquiry::factory()->create([
|
||||
'user_id' => $this->user->id,
|
||||
'product_id' => $product->id,
|
||||
'user_id' => $this->user->id,
|
||||
'product_id' => $product->id,
|
||||
'is_answered' => true,
|
||||
]);
|
||||
ProductInquiry::factory()->create([
|
||||
'user_id' => $this->user->id,
|
||||
'product_id' => $product->id,
|
||||
'user_id' => $this->user->id,
|
||||
'product_id' => $product->id,
|
||||
'is_answered' => false,
|
||||
]);
|
||||
|
||||
@@ -123,13 +156,13 @@ class UserInquiryControllerTest extends ModuleTestCase
|
||||
{
|
||||
$product = Product::factory()->create();
|
||||
ProductInquiry::factory()->create([
|
||||
'user_id' => $this->user->id,
|
||||
'product_id' => $product->id,
|
||||
'user_id' => $this->user->id,
|
||||
'product_id' => $product->id,
|
||||
'is_answered' => true,
|
||||
]);
|
||||
ProductInquiry::factory()->create([
|
||||
'user_id' => $this->user->id,
|
||||
'product_id' => $product->id,
|
||||
'user_id' => $this->user->id,
|
||||
'product_id' => $product->id,
|
||||
'is_answered' => false,
|
||||
]);
|
||||
|
||||
@@ -151,13 +184,13 @@ class UserInquiryControllerTest extends ModuleTestCase
|
||||
{
|
||||
$product = Product::factory()->create();
|
||||
ProductInquiry::factory()->create([
|
||||
'user_id' => $this->user->id,
|
||||
'product_id' => $product->id,
|
||||
'user_id' => $this->user->id,
|
||||
'product_id' => $product->id,
|
||||
'product_name_snapshot' => ['ko' => '사과 1박스', 'en' => 'Apple Box'],
|
||||
]);
|
||||
ProductInquiry::factory()->create([
|
||||
'user_id' => $this->user->id,
|
||||
'product_id' => $product->id,
|
||||
'user_id' => $this->user->id,
|
||||
'product_id' => $product->id,
|
||||
'product_name_snapshot' => ['ko' => 'USB 충전 케이블', 'en' => 'USB Cable'],
|
||||
]);
|
||||
|
||||
@@ -181,7 +214,7 @@ class UserInquiryControllerTest extends ModuleTestCase
|
||||
{
|
||||
$product = Product::factory()->create();
|
||||
ProductInquiry::factory()->count(3)->create([
|
||||
'user_id' => $this->user->id,
|
||||
'user_id' => $this->user->id,
|
||||
'product_id' => $product->id,
|
||||
]);
|
||||
|
||||
@@ -201,7 +234,7 @@ class UserInquiryControllerTest extends ModuleTestCase
|
||||
{
|
||||
$product = Product::factory()->create();
|
||||
ProductInquiry::factory()->create([
|
||||
'user_id' => $this->user->id,
|
||||
'user_id' => $this->user->id,
|
||||
'product_id' => $product->id,
|
||||
]);
|
||||
|
||||
|
||||
+37
-8
@@ -2,6 +2,11 @@
|
||||
|
||||
namespace Modules\Sirsoft\Ecommerce\Tests\Feature\Search;
|
||||
|
||||
use App\Extension\HookListenerRegistrar;
|
||||
use App\Extension\HookManager;
|
||||
use App\Extension\ModuleManager;
|
||||
use Illuminate\Support\Facades\Config;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Modules\Sirsoft\Ecommerce\Enums\ProductDisplayStatus;
|
||||
use Modules\Sirsoft\Ecommerce\Models\Product;
|
||||
use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase;
|
||||
@@ -46,7 +51,7 @@ class ProductSearchIntegrationTest extends ModuleTestCase
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$module = app(\App\Extension\ModuleManager::class)->getModule('sirsoft-ecommerce');
|
||||
$module = app(ModuleManager::class)->getModule('sirsoft-ecommerce');
|
||||
if ($module === null) {
|
||||
return;
|
||||
}
|
||||
@@ -57,7 +62,7 @@ class ProductSearchIntegrationTest extends ModuleTestCase
|
||||
// filter 큐에 중복 추가되어 searchProducts 가 한 요청에서 2회 실행되어 두 번째
|
||||
// 호출이 첫 번째 결과를 빈 결과로 덮어쓰는 문제 발생.
|
||||
// - registrar dedup 캐시도 함께 비워 register() 가 실제 동작하도록 한다.
|
||||
\App\Extension\HookListenerRegistrar::clear();
|
||||
HookListenerRegistrar::clear();
|
||||
|
||||
foreach ($module->getHookListeners() as $listenerClass) {
|
||||
if (! class_exists($listenerClass)) {
|
||||
@@ -66,10 +71,10 @@ class ProductSearchIntegrationTest extends ModuleTestCase
|
||||
try {
|
||||
$subscribed = $listenerClass::getSubscribedHooks();
|
||||
foreach (array_keys($subscribed) as $hookName) {
|
||||
\App\Extension\HookManager::clearFilter($hookName);
|
||||
\App\Extension\HookManager::clearAction($hookName);
|
||||
HookManager::clearFilter($hookName);
|
||||
HookManager::clearAction($hookName);
|
||||
}
|
||||
\App\Extension\HookListenerRegistrar::register($listenerClass, 'sirsoft-ecommerce');
|
||||
HookListenerRegistrar::register($listenerClass, 'sirsoft-ecommerce');
|
||||
} catch (\Throwable $e) {
|
||||
// skip individual listener failures
|
||||
}
|
||||
@@ -88,7 +93,7 @@ class ProductSearchIntegrationTest extends ModuleTestCase
|
||||
*/
|
||||
protected function flushProductFulltextIndex(): void
|
||||
{
|
||||
\Illuminate\Support\Facades\DB::statement('ALTER TABLE g7_ecommerce_products ENGINE=InnoDB');
|
||||
DB::statement('ALTER TABLE g7_ecommerce_products ENGINE=InnoDB');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -249,6 +254,31 @@ class ProductSearchIntegrationTest extends ModuleTestCase
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 통합검색 상품 링크가 상점 주소 설정을 따르는지 확인 (공개 #85)
|
||||
*
|
||||
* 기본값 리터럴을 내려보내면 상점 주소를 바꾼 사이트의 검색 결과가 전부
|
||||
* 존재하지 않는 상품 화면을 가리킨다 — 링크는 만들어졌으므로 오류가 남지 않는다.
|
||||
*/
|
||||
public function test_search_product_url_follows_shop_route_path_setting(): void
|
||||
{
|
||||
Config::set('g7_settings.modules.sirsoft-ecommerce.basic_info', ['route_path' => 'store']);
|
||||
|
||||
$keyword = 'routepathurlkeyword';
|
||||
$product = Product::factory()->create([
|
||||
'name' => ['ko' => '주소확인용 상품', 'en' => "{$keyword} Route Path"],
|
||||
'display_status' => ProductDisplayStatus::VISIBLE,
|
||||
]);
|
||||
$this->flushProductFulltextIndex();
|
||||
|
||||
$response = $this->getJson("/api/search?q={$keyword}&type=products");
|
||||
$response->assertStatus(200);
|
||||
|
||||
$products = $response->json('data.products') ?? [];
|
||||
$this->assertNotEmpty($products, '검색 결과가 비어 URL 축을 검증할 수 없다');
|
||||
$this->assertSame("/store/products/{$product->product_code}", $products[0]['url']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 품절/판매중단 상품이 검색 결과에 포함되는지 확인
|
||||
*
|
||||
@@ -326,5 +356,4 @@ class ProductSearchIntegrationTest extends ModuleTestCase
|
||||
$ids = collect($data['products'] ?? [])->pluck('id')->toArray();
|
||||
$this->assertContains($product->id, $ids);
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
+17
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
namespace Modules\Sirsoft\Ecommerce\Tests\Unit\Listeners;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Config;
|
||||
use Modules\Sirsoft\Ecommerce\Database\Factories\OrderFactory;
|
||||
use Modules\Sirsoft\Ecommerce\Listeners\EcommerceNotificationDataListener;
|
||||
use Modules\Sirsoft\Ecommerce\Models\Order;
|
||||
@@ -123,6 +124,22 @@ class EcommerceNotificationDataListenerTest extends ModuleTestCase
|
||||
$this->assertStringNotContainsString('ORD-GUEST-1', $result['data']['order_url']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 비회원 주문 안내 메일의 조회 주소는 상점 주소 설정을 따른다 (공개 #85).
|
||||
*
|
||||
* 상수로 `/shop` 을 박아 두면 주소를 바꾼 상점의 손님이 받은 메일이 존재하지 않는
|
||||
* 화면을 가리킨다 — 메일은 정상 발송되므로 아무 오류도 남지 않는다.
|
||||
*/
|
||||
public function test_guest_lookup_url_follows_shop_route_path_setting(): void
|
||||
{
|
||||
Config::set('g7_settings.modules.sirsoft-ecommerce.basic_info', ['route_path' => 'store']);
|
||||
|
||||
$order = $this->makeGuestOrder('비회원주문자', 'guest@example.com', 'ORD-GUEST-2');
|
||||
$result = $this->listener->extractData($this->emptyDefault(), 'order_confirmed', [$order]);
|
||||
|
||||
$this->assertStringEndsWith('/store/guest/orders', $result['data']['order_url']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 비회원 주문 컨텍스트 → 코어 알림 표준 키 guest_recipient 제공 (회원과 동일 발송 경로).
|
||||
*/
|
||||
|
||||
+71
@@ -6,6 +6,7 @@ use App\Jobs\GenerateSitemapJob;
|
||||
use App\Seo\Contracts\SeoCacheManagerInterface;
|
||||
use App\Seo\SitemapIndexer;
|
||||
use Illuminate\Support\Facades\Bus;
|
||||
use Illuminate\Support\Facades\Config;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Mockery;
|
||||
use Modules\Sirsoft\Ecommerce\Listeners\SeoCategoryCacheListener;
|
||||
@@ -218,6 +219,76 @@ class SeoCategoryCacheListenerTest extends ModuleTestCase
|
||||
$this->addToAssertionCount(1);
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// syncSitemapIndex() — 상점 주소 설정 반영 (공개 #85)
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* 사이트맵 색인 URL 이 기본 상점 주소를 따르는지 확인
|
||||
*/
|
||||
public function test_sitemap_index_url_uses_default_shop_path(): void
|
||||
{
|
||||
$this->assertSitemapIndexUrl([], '/shop/category/outer');
|
||||
}
|
||||
|
||||
/**
|
||||
* 운영자가 상점 주소를 바꾸면 사이트맵 색인 URL 도 그 주소를 따르는지 확인
|
||||
*/
|
||||
public function test_sitemap_index_url_follows_custom_route_path(): void
|
||||
{
|
||||
$this->assertSitemapIndexUrl(['route_path' => 'store'], '/store/category/outer');
|
||||
}
|
||||
|
||||
/**
|
||||
* 주소 없이 운영하는 상점(no_route)의 사이트맵 색인 URL 은 세그먼트 없이 루트에 붙는다
|
||||
*
|
||||
* 종전에는 route_path 만 읽고 no_route 를 무시해 `/shop/category/outer` 를 색인했다 —
|
||||
* 실제 화면 주소는 `/category/outer` 이므로 사이트맵이 없는 주소를 검색엔진에 알린다.
|
||||
*/
|
||||
public function test_sitemap_index_url_omits_route_segment_when_no_route(): void
|
||||
{
|
||||
$this->assertSitemapIndexUrl(
|
||||
['route_path' => 'shop', 'no_route' => true],
|
||||
'/category/outer'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 주어진 상점 주소 설정에서 색인된 카테고리 URL 이 기대값과 같은지 검증합니다.
|
||||
*
|
||||
* @param array $basicInfo 주입할 `basic_info` 설정
|
||||
* @param string $expectedUrl 기대하는 색인 URL
|
||||
*/
|
||||
private function assertSitemapIndexUrl(array $basicInfo, string $expectedUrl): void
|
||||
{
|
||||
Config::set('g7_settings.modules.sirsoft-ecommerce.basic_info', $basicInfo);
|
||||
|
||||
$indexedUrl = null;
|
||||
$indexer = Mockery::mock(SitemapIndexer::class);
|
||||
$indexer->shouldReceive('indexResource')
|
||||
->once()
|
||||
->andReturnUsing(function (string $type, int $id, string $owner, array $urls) use (&$indexedUrl) {
|
||||
$indexedUrl = $urls[0]['url'] ?? null;
|
||||
});
|
||||
$this->app->instance(SitemapIndexer::class, $indexer);
|
||||
|
||||
$this->app->instance(SeoCacheManagerInterface::class, $this->createMock(SeoCacheManagerInterface::class));
|
||||
Log::shouldReceive('debug')->atLeast()->once();
|
||||
|
||||
$category = (object) [
|
||||
'id' => 42,
|
||||
'slug' => 'outer',
|
||||
'is_active' => true,
|
||||
'updated_at' => null,
|
||||
];
|
||||
|
||||
$this->listener->onCategoryChange($category);
|
||||
|
||||
// 존재 확정 후 값 비교 — 색인 자체가 일어나지 않으면 URL 비교는 의미가 없다
|
||||
$this->assertNotNull($indexedUrl, '카테고리 사이트맵 색인이 수행되지 않았다');
|
||||
$this->assertSame($expectedUrl, $indexedUrl);
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// handle() 메서드 테스트
|
||||
// ========================================
|
||||
|
||||
+30
@@ -7,6 +7,7 @@ use App\Seo\Contracts\SeoCacheManagerInterface;
|
||||
use App\Seo\SeoCacheRegenerator;
|
||||
use App\Seo\SitemapIndexer;
|
||||
use Illuminate\Support\Facades\Bus;
|
||||
use Illuminate\Support\Facades\Config;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Mockery;
|
||||
use Modules\Sirsoft\Ecommerce\Listeners\SeoProductCacheListener;
|
||||
@@ -120,6 +121,35 @@ class SeoProductCacheListenerTest extends ModuleTestCase
|
||||
$this->addToAssertionCount(1);
|
||||
}
|
||||
|
||||
/**
|
||||
* 주소 없이 운영하는 상점(no_route)의 SEO 캐시 경로는 세그먼트 없이 루트에 붙는다 (공개 #85)
|
||||
*
|
||||
* 종전에는 route_path 만 읽고 no_route 를 무시해 `/shop/products/7` 을 재생성했다 —
|
||||
* 실제 화면 주소는 `/products/7` 이므로 봇이 받는 캐시가 영원히 채워지지 않는다.
|
||||
*/
|
||||
public function test_on_product_update_regenerates_root_path_when_no_route(): void
|
||||
{
|
||||
Config::set('g7_settings.modules.sirsoft-ecommerce.basic_info', [
|
||||
'route_path' => 'shop',
|
||||
'no_route' => true,
|
||||
]);
|
||||
|
||||
$product = (object) ['id' => 7];
|
||||
|
||||
$this->expectCommonInvalidations($product);
|
||||
|
||||
$this->regeneratorMock->shouldReceive('renderAndCache')
|
||||
->once()
|
||||
->with('/products/7')
|
||||
->andReturn(true);
|
||||
|
||||
Log::shouldReceive('debug')->atLeast()->once();
|
||||
|
||||
$this->listener->onProductUpdate($product);
|
||||
|
||||
$this->addToAssertionCount(1);
|
||||
}
|
||||
|
||||
/**
|
||||
* 상품 수정 시 home과 search/index 캐시도 무효화되는지 확인
|
||||
*/
|
||||
|
||||
@@ -82,6 +82,26 @@ class EcommerceSitemapContributorTest extends ModuleTestCase
|
||||
$this->assertContains('/shop/products', $urlPaths);
|
||||
}
|
||||
|
||||
/**
|
||||
* getUrls: 주소 없이 운영하는 상점(no_route)은 세그먼트 없이 루트 경로를 싣는다 (공개 #85)
|
||||
*
|
||||
* route_path 만 읽고 no_route 를 무시하면 사이트맵이 `/shop/...` 을 검색엔진에
|
||||
* 제출하지만 실제 화면은 `/...` 이다 — 색인된 URL 전부가 404 로 수집된다.
|
||||
*/
|
||||
public function test_get_urls_omits_route_segment_when_no_route(): void
|
||||
{
|
||||
Config::set('g7_settings.modules.sirsoft-ecommerce.basic_info', [
|
||||
'route_path' => 'shop',
|
||||
'no_route' => true,
|
||||
]);
|
||||
|
||||
$contributor = $this->app->make(EcommerceSitemapContributor::class);
|
||||
$urlPaths = array_column($contributor->getUrls(), 'url');
|
||||
|
||||
$this->assertContains('/products', $urlPaths);
|
||||
$this->assertNotContains('/shop/products', $urlPaths);
|
||||
}
|
||||
|
||||
/**
|
||||
* getUrls: 정적/카테고리/상품 URL 항목이 각각 올바른 키 구조를 가진다
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Sirsoft\Ecommerce\Tests\Unit\Support;
|
||||
|
||||
use Illuminate\Support\Facades\Config;
|
||||
use Modules\Sirsoft\Ecommerce\Support\ShopPathResolver;
|
||||
use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
|
||||
/**
|
||||
* 상점 경로 해석기 단위 테스트
|
||||
*
|
||||
* 상점 주소는 운영자가 바꿀 수 있고(`basic_info.route_path`), 아예 주소 없이 루트에
|
||||
* 붙일 수도 있다(`basic_info.no_route`). 그런데 주문 완료 이동·알림 메일·검색 결과·
|
||||
* 사이트맵이 각자 `/shop` 을 문자열로 조립해 왔다. 기본값을 쓰지 않는 상점에서는
|
||||
* 그 주소가 전부 존재하지 않는 페이지를 가리킨다 — 예외도 404 로그도 남지 않고
|
||||
* 링크만 조용히 죽는다.
|
||||
*
|
||||
* 이 테스트는 해석 규칙 자체를 고정한다. 소비 지점의 반영 여부는 각 지점 테스트가 본다.
|
||||
*
|
||||
* 결제 플러그인 4종의 리다이렉트 주소도 이 해석기를 SSoT 로 삼는다
|
||||
* (plugins/_bundled/sirsoft-tosspayments/tests/scenarios/payment-redirect-shop-route-path.yaml).
|
||||
*
|
||||
* @scenario case=payment_redirect_follows_shop_route_path
|
||||
*
|
||||
* @effects payment_redirect_follows_route_path, payment_redirect_drops_segment_when_no_route,
|
||||
* default_shop_redirect_unchanged
|
||||
*/
|
||||
class ShopPathResolverTest extends ModuleTestCase
|
||||
{
|
||||
/**
|
||||
* 상점 기본 설정을 주입합니다.
|
||||
*
|
||||
* 해석기는 `g7_module_settings()` 를 통해 설정을 읽으므로 부팅 시 적재되는
|
||||
* `g7_settings.modules.*` 배열을 직접 주입해 파일 I/O 없이 축을 만든다.
|
||||
*
|
||||
* @param array<string, mixed> $basicInfo basic_info 설정 값
|
||||
*/
|
||||
private function setBasicInfo(array $basicInfo): void
|
||||
{
|
||||
Config::set('g7_settings.modules.sirsoft-ecommerce.basic_info', $basicInfo);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function 설정이_없으면_기본_상점_주소로_폴백한다(): void
|
||||
{
|
||||
Config::set('g7_settings.modules.sirsoft-ecommerce', []);
|
||||
|
||||
$this->assertSame('/shop', ShopPathResolver::base());
|
||||
$this->assertSame('/shop/products/A1', ShopPathResolver::path('products/A1'));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function 운영자가_바꾼_주소를_반영한다(): void
|
||||
{
|
||||
$this->setBasicInfo(['route_path' => 'store']);
|
||||
|
||||
$this->assertSame('/store', ShopPathResolver::base());
|
||||
$this->assertSame('/store/orders/20260806-0001/complete', ShopPathResolver::path('orders/20260806-0001/complete'));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function 주소_없이_운영하면_루트에_붙는다(): void
|
||||
{
|
||||
$this->setBasicInfo(['route_path' => 'shop', 'no_route' => true]);
|
||||
|
||||
$this->assertSame('', ShopPathResolver::base());
|
||||
$this->assertSame('/products/A1', ShopPathResolver::path('products/A1'));
|
||||
$this->assertSame('/', ShopPathResolver::path(''));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function 주소가_빈_문자열이면_기본값으로_폴백한다(): void
|
||||
{
|
||||
$this->setBasicInfo(['route_path' => '']);
|
||||
|
||||
$this->assertSame('/shop', ShopPathResolver::base());
|
||||
$this->assertSame('/shop/guest/orders', ShopPathResolver::path('guest/orders'));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function 앞뒤_슬래시가_붙은_주소도_한_겹으로_정규화한다(): void
|
||||
{
|
||||
$this->setBasicInfo(['route_path' => '/store/']);
|
||||
|
||||
$this->assertSame('/store', ShopPathResolver::base());
|
||||
$this->assertSame('/store/products', ShopPathResolver::path('/products'));
|
||||
$this->assertSame('/store/products', ShopPathResolver::path('products'));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function no_route_가_꺼져_있으면_주소를_유지한다(): void
|
||||
{
|
||||
$this->setBasicInfo(['route_path' => 'store', 'no_route' => false]);
|
||||
|
||||
$this->assertSame('/store', ShopPathResolver::base());
|
||||
$this->assertSame('/store/category/tops', ShopPathResolver::path('category/tops'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
# audit:allow test-scenario-coverage reason: |
|
||||
# cross product 자동 전개는 audit 실행 환경에 js-yaml 이 없어 fallback YAML 파서가
|
||||
# nested axes 배열을 읽지 못하는 한계로 검출되지 않는다(동일 한계로 다른 번들 시나리오도
|
||||
# audit:allow 처리됨). 아래 조합은 PHPUnit 5종(해석기 단위 / 주문 생성 Feature /
|
||||
# 비회원 조회 안내 / 알림 메일 / 통합검색 / SEO·사이트맵)으로 전수 커버되어 green 이다.
|
||||
# 프론트는 서버 값 우선 + 폴백 구조라 변경이 없어 레이아웃·E2E 축을 두지 않는다.
|
||||
feature: 서버가 만드는 상점 주소의 route_path/no_route 반영 (공개 #85)
|
||||
|
||||
description: |
|
||||
상점 주소는 운영자 설정이다 — `basic_info.route_path` 로 바꿀 수 있고,
|
||||
`basic_info.no_route` 를 켜면 주소 세그먼트 없이 루트에 붙는다. 그런데 서버가 만들어
|
||||
내보내던 상점 주소는 각 지점에서 `/shop` 리터럴이나 `"/{$routePath}/..."` 조립으로
|
||||
흩어져 있었다.
|
||||
|
||||
기본값이 아닌 상점에서는 그 주소가 존재하지 않는 화면을 가리키는데, 서버는 문자열을
|
||||
만들어 내려보냈을 뿐이라 예외도 404 로그도 남지 않는다 — 링크만 조용히 죽는다.
|
||||
|
||||
ShopPathResolver 를 단일 해석 지점으로 두고 서버 생성 주소 전 지점을 경유시킨다.
|
||||
프론트 소비처(_checkout_summary.json)는 서버 값 우선 + `_global.shopBase` 폴백
|
||||
구조라 변경하지 않는다.
|
||||
|
||||
범위 제외: PG 플러그인 4종의 `redirect_success_url` 설정 기본값 (후속 이슈 분리).
|
||||
|
||||
axes:
|
||||
route_setting:
|
||||
- default # route_path 미설정/기본값 'shop'
|
||||
- custom # 운영자가 바꾼 주소 (예: 'store')
|
||||
- no_route # 주소 없이 루트 운영
|
||||
- empty_value # route_path 빈 문자열 → 기본값 폴백
|
||||
- slashed_value # 앞뒤 슬래시 포함 값 → 한 겹 정규화
|
||||
surface:
|
||||
- order_complete_redirect # 주문 생성 응답 redirect_url
|
||||
- guest_lookup_redirect # 비회원 주문 404 응답 redirect_to
|
||||
- notification_order_url # 알림 메일 비회원 주문 조회 URL
|
||||
- search_product_url # 통합검색 상품 링크
|
||||
- inquiry_product_url # 상품문의 목록의 상품 링크
|
||||
- seo_detail_cache # 상품 상세 SEO 캐시 재생성 경로
|
||||
- seo_sitemap_index # 사이트맵 상품/카테고리/목록 URL
|
||||
order_actor:
|
||||
- guest
|
||||
- member
|
||||
|
||||
exclusions:
|
||||
- { surface: guest_lookup_redirect, order_actor: member, reason: "회원 분기는 /mypage/orders 로 상점 주소와 무관" }
|
||||
- { surface: notification_order_url, order_actor: member, reason: "회원 알림은 마이페이지 URL 사용" }
|
||||
- { surface: search_product_url, order_actor: member, reason: "검색 링크는 주문 주체와 무관" }
|
||||
- { surface: inquiry_product_url, order_actor: member, reason: "문의 링크는 주문 주체와 무관" }
|
||||
- { surface: seo_detail_cache, order_actor: member, reason: "봇 렌더 경로라 주문 주체 없음" }
|
||||
- { surface: seo_sitemap_index, order_actor: member, reason: "봇 렌더 경로라 주문 주체 없음" }
|
||||
|
||||
effects:
|
||||
# 해석 규칙 (SSoT)
|
||||
- resolver_falls_back_to_default_shop_when_unset # 설정 없음 → /shop
|
||||
- resolver_reflects_custom_route_path # 변경 주소 반영
|
||||
- resolver_returns_empty_base_when_no_route # no_route → 기준 경로 빈 문자열
|
||||
- resolver_falls_back_when_route_path_is_blank # 빈 문자열 → 기본값
|
||||
- resolver_normalizes_duplicate_slashes # 슬래시 중복 한 겹 정규화
|
||||
- resolver_keeps_route_when_no_route_is_false # no_route=false 는 주소 유지
|
||||
# 소비 지점
|
||||
- order_complete_redirect_follows_route_path # 주문 완료 이동 주소
|
||||
- order_complete_redirect_drops_segment_when_no_route # no_route 시 루트 경로
|
||||
- guest_lookup_redirect_follows_route_path # 비회원 조회 안내 404 응답
|
||||
- guest_lookup_redirect_drops_segment_when_no_route
|
||||
- notification_guest_order_url_follows_route_path # 알림 메일 조회 링크
|
||||
- search_product_url_follows_route_path # 통합검색 상품 링크
|
||||
- inquiry_product_url_follows_no_route # 상품문의 목록의 상품 링크
|
||||
- seo_detail_cache_path_follows_no_route # SEO 캐시 재생성 경로
|
||||
- sitemap_urls_omit_segment_when_no_route # 사이트맵 URL
|
||||
- category_sitemap_index_url_follows_route_path # 카테고리 사이트맵 증분 색인 URL
|
||||
# 회귀
|
||||
- default_shop_urls_unchanged # 기본값 상점의 주소는 종전과 동일
|
||||
|
||||
test_files:
|
||||
- modules/_bundled/sirsoft-ecommerce/tests/Unit/Support/ShopPathResolverTest.php
|
||||
- modules/_bundled/sirsoft-ecommerce/tests/Feature/Http/Controllers/Public/OrderControllerTest.php
|
||||
- modules/_bundled/sirsoft-ecommerce/tests/Unit/Listeners/EcommerceNotificationDataListenerTest.php
|
||||
- modules/_bundled/sirsoft-ecommerce/tests/Feature/Search/ProductSearchIntegrationTest.php
|
||||
- modules/_bundled/sirsoft-ecommerce/tests/Feature/Http/Controllers/Public/UserInquiryControllerTest.php
|
||||
- modules/_bundled/sirsoft-ecommerce/tests/Unit/Listeners/SeoProductCacheListenerTest.php
|
||||
- modules/_bundled/sirsoft-ecommerce/tests/Unit/Listeners/SeoCategoryCacheListenerTest.php
|
||||
- modules/_bundled/sirsoft-ecommerce/tests/Unit/Seo/EcommerceSitemapContributorTest.php
|
||||
|
||||
validation:
|
||||
audit_rule: test-scenario-coverage
|
||||
Reference in new issue
Block a user